mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f0bf911a0 | ||
|
|
2cac075c0e | ||
|
|
94906a068a | ||
|
|
e72b863362 | ||
|
|
d705cfc30b | ||
|
|
02e307f758 | ||
|
|
5ad1d1d4b3 | ||
|
|
f480449301 | ||
|
|
96df5652d6 | ||
|
|
a9e05d2c0f | ||
|
|
c982b3e90f | ||
|
|
cfaafc07b2 | ||
|
|
94ad99dc4d | ||
|
|
baf644d62b | ||
|
|
f9a5420ad2 | ||
|
|
f8361174f2 | ||
|
|
64df0660c8 | ||
|
|
bbfa2a38e9 | ||
|
|
94ec3f45ce | ||
|
|
a6747b12f0 | ||
|
|
7a641b2618 | ||
|
|
edefcb8409 | ||
|
|
5ee986fab8 | ||
|
|
c1573dba08 | ||
|
|
9576abc979 | ||
|
|
caa3f3d35a | ||
|
|
2a6f0fc302 | ||
|
|
4da5b7b9c4 | ||
|
|
1ba42592d7 | ||
|
|
bc6f1a7d41 | ||
|
|
539502d7ff | ||
|
|
b5771dd914 | ||
|
|
a816b6ec32 | ||
|
|
54c9484cb5 | ||
|
|
36e26318cd | ||
|
|
8abd580295 | ||
|
|
80294c9876 | ||
|
|
148b549c83 | ||
|
|
a3b4065984 | ||
|
|
620227307d | ||
|
|
4426b75c6f | ||
|
|
b7e12f7010 | ||
|
|
f0ab54cd60 | ||
|
|
72a7a854d0 | ||
|
|
50d0638c5c | ||
|
|
7a3493e54f | ||
|
|
b1a5784bcc | ||
|
|
24cb99658e | ||
|
|
c042cf4580 |
@@ -23,3 +23,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install rpmbuild
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download external tools (pandoc)
|
||||||
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build Linux packages
|
- name: Build Linux packages
|
||||||
run: npm run build:linux
|
run: npm run build:linux-ci -- --publish=never
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
- name: Upload Linux artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: ubuntu-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -53,17 +53,37 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install Wine and NSIS
|
|
||||||
run: |
|
|
||||||
sudo dpkg --add-architecture i386
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y wine64 wine32 nsis
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Windows packages
|
- name: Download external tools (pandoc)
|
||||||
run: npm run build:win
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Decode certificate (if available)
|
||||||
|
if: ${{ env.CSC_LINK_BASE64 != '' }}
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
|
||||||
|
run: |
|
||||||
|
$bytes = [Convert]::FromBase64String("$env:CSC_LINK_BASE64")
|
||||||
|
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
|
||||||
|
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build Windows packages (signed)
|
||||||
|
if: ${{ env.CERT_AVAILABLE == 'true' }}
|
||||||
|
env:
|
||||||
|
CSC_LINK: code-signing-cert.pfx
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
run: npm run build:win-signed -- --publish=never
|
||||||
|
|
||||||
|
- name: Build Windows packages (unsigned)
|
||||||
|
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||||
|
run: npm run build:win-unsigned -- --publish=never
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
- name: Upload Windows artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -71,23 +91,26 @@ jobs:
|
|||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: |
|
path: |
|
||||||
dist/*.exe
|
dist/*.exe
|
||||||
dist/*-win.zip
|
dist/*.zip
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows]
|
||||||
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
- name: Download Linux artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: linux-artifacts
|
name: linux-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|||||||
+15
-3
@@ -8,14 +8,22 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.vscode/
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
out/
|
out/
|
||||||
.cache/
|
.cache/
|
||||||
.npm/
|
.npm/
|
||||||
.electron/
|
.electron/
|
||||||
package-lock.json
|
# package-lock.json is intentionally tracked for reproducible CI builds
|
||||||
|
|
||||||
|
# Downloaded tool binaries (fetched at build time via scripts/download-tools.js)
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Code signing certificates — never commit private keys
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
|
||||||
# Screenshots and temp files
|
# Screenshots and temp files
|
||||||
*.png.bak
|
*.png.bak
|
||||||
@@ -33,4 +41,8 @@ pdf\ modal.png
|
|||||||
# Claude/AI development files
|
# Claude/AI development files
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
agents.mdcoverage/
|
agents.md
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Superpowers brainstorm artifacts
|
||||||
|
.superpowers/
|
||||||
|
|||||||
Vendored
+48
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Main Process",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"args": ["."],
|
||||||
|
"outputCapture": "std",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Renderer Process",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 9222,
|
||||||
|
"webRoot": "${workspaceFolder}/src",
|
||||||
|
"timeout": 30000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Main + Renderer",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"args": [".", "--remote-debugging-port=9222"],
|
||||||
|
"outputCapture": "std",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
},
|
||||||
|
"serverReadyAction": {
|
||||||
|
"pattern": "listening on port ([0-9]+)",
|
||||||
|
"uriFormat": "http://localhost:%s",
|
||||||
|
"action": "debugWithChrome"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
Core application code lives in `src/`. Use `src/main.js` for the Electron main process, `src/preload.js` for the preload bridge, and `src/renderer.js` plus `src/editor/`, `src/sidebar/`, `src/repl/`, and `src/utils/` for renderer-side features. Electron adapter code is in `src/adapters/electron/`. Reusable markdown/document templates live in `src/templates/`. Static assets and icons are in `assets/`. Tests are in `tests/`, and build output goes to `dist/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `npm start`: launch the Electron app locally.
|
||||||
|
- `npm test`: run the Jest suite once.
|
||||||
|
- `npm run test:watch`: rerun tests during local development.
|
||||||
|
- `npm run test:coverage`: generate coverage output.
|
||||||
|
- `npm run lint` / `npm run lint:fix`: check or fix ESLint issues in `src` and `tests`.
|
||||||
|
- `npm run format` / `npm run format:check`: apply or verify Prettier formatting.
|
||||||
|
- `npm run build:linux`, `npm run build:win`, `npm run build:mac`: create platform packages with `electron-builder`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
This repo uses Prettier and ESLint. Follow `.prettierrc`: 2-space indentation, single quotes, semicolons, trailing commas where valid in ES5, and a 100-character line width. Prefer `camelCase` for variables/functions, `PascalCase` for classes, and kebab-case for file names only when already established. Keep module boundaries clear: UI logic in renderer modules, OS/file-system work behind Electron IPC and adapters.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Tests use Jest with `jest-environment-jsdom`. Add new tests under `tests/` with `*.test.js` names, mirroring the feature area when possible, for example `tests/sidebar.test.js` or `tests/print-preview.test.js`. Update or add regression tests for renderer behavior, preload APIs, and utility helpers when fixing bugs. Run `npm test` before opening a PR; use `npm run test:coverage` for larger refactors.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `refactor:`. Keep subjects short and imperative, for example `fix: guard modal cleanup on close`. PRs should describe the user-visible change, note test coverage, link any related issue, and include screenshots or GIFs for UI changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Do not bypass preload boundaries or introduce direct `eval`/dynamic code paths; ESLint already treats these as errors. Export and conversion features depend on external tools such as Pandoc, FFmpeg, ImageMagick, and LibreOffice, so document any new runtime dependency in `README.md` and packaging config.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# CLAUDE.md — MarkdownConverter (master)
|
||||||
|
|
||||||
|
> General code-quality, JavaScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, and a plugin system.
|
||||||
|
|
||||||
|
- **Version:** 4.4.5
|
||||||
|
- **License:** MIT
|
||||||
|
- **App ID:** `com.concreteinfo.markdownconverter`
|
||||||
|
|
||||||
|
## Branch Specifics
|
||||||
|
|
||||||
|
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Main Process (`src/main.js` — 4,260 lines)
|
||||||
|
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
|
||||||
|
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
|
||||||
|
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
|
||||||
|
|
||||||
|
### Renderer (`src/renderer.js` — 5,361 lines)
|
||||||
|
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
|
||||||
|
|
||||||
|
### Preload (`src/preload.js` — 448 lines)
|
||||||
|
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
|
||||||
|
|
||||||
|
### Security Model
|
||||||
|
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
|
||||||
|
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
|
||||||
|
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
|
||||||
|
- Permission handler only allows `clipboard-read`/`clipboard-write`
|
||||||
|
- Rate limiter on conversions (2-second minimum interval)
|
||||||
|
- File size limit: 50MB
|
||||||
|
- Error message sanitization strips absolute paths
|
||||||
|
|
||||||
|
### Plugin System (`src/plugins/`)
|
||||||
|
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
|
||||||
|
|
||||||
|
## System Dependencies
|
||||||
|
|
||||||
|
| Dependency | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
|
||||||
|
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
|
||||||
|
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
|
||||||
|
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
|
||||||
|
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
|
||||||
|
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # Launch Electron app (dev mode)
|
||||||
|
npm test # Jest test suite
|
||||||
|
npm test:watch # Jest in watch mode
|
||||||
|
npm test:coverage # Jest with coverage report
|
||||||
|
npm run lint # ESLint check (src + tests)
|
||||||
|
npm run lint:fix # ESLint auto-fix
|
||||||
|
npm run format # Prettier write
|
||||||
|
npm run format:check # Prettier check only
|
||||||
|
npm run download-tools # Download Pandoc binaries to bin/
|
||||||
|
npm run generate-icons # Generate app icons via sharp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Package
|
||||||
|
|
||||||
|
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
|
||||||
|
|
||||||
|
| Target | Platforms |
|
||||||
|
|---|---|
|
||||||
|
| `npm run build` | electron-builder (default platform) |
|
||||||
|
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
|
||||||
|
| `npm run build:mac` | macOS: default dmg |
|
||||||
|
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
||||||
|
| `npm run dist` | Build without publish |
|
||||||
|
| `npm run dist:all` | Build for all platforms |
|
||||||
|
|
||||||
|
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
|
||||||
|
|
||||||
|
**Output:** `dist/` directory.
|
||||||
|
|
||||||
|
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
||||||
|
|
||||||
|
## Project Conventions / Gotchas
|
||||||
|
|
||||||
|
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
|
||||||
|
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
|
||||||
|
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
|
||||||
|
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
|
||||||
|
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
|
||||||
|
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
|
||||||
|
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
||||||
|
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
|
||||||
|
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
|
||||||
|
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
||||||
|
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
|
||||||
|
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
|
||||||
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v3.0.0
|
v4.4.5
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MarkdownConverter - STRIDE Threat Model Analysis
|
# MarkdownConverter - STRIDE Threat Model Analysis
|
||||||
|
|
||||||
**Version:** 4.0.0
|
**Version:** 4.1.0
|
||||||
**Date:** 2026-03-15
|
**Date:** 2026-03-15
|
||||||
**Methodology:** STRIDE + MITRE ATT&CK Mapping
|
**Methodology:** STRIDE + MITRE ATT&CK Mapping
|
||||||
**Analyst:** Security Assessment Team
|
**Analyst:** Security Assessment Team
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# Writer's Studio Feature Pack — Design Document
|
||||||
|
|
||||||
|
**Date**: 2026-04-06
|
||||||
|
**Version**: 4.2.0 target
|
||||||
|
**Status**: Approved
|
||||||
|
**Scope**: Three cohesive features to transform MarkdownConverter into a writing environment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Writer's Studio Feature Pack adds three interconnected features to MarkdownConverter:
|
||||||
|
|
||||||
|
1. **Zen Mode** — Distraction-free writing environment with typewriter scrolling
|
||||||
|
2. **Document Outline** — Heading hierarchy sidebar panel for navigation
|
||||||
|
3. **Writing Analytics** — Real-time readability and vocabulary analysis dashboard
|
||||||
|
|
||||||
|
These features work together: Zen Mode creates the environment, Outline provides navigation, Analytics gives insight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 1: Zen Mode
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Transform the app from a multi-tool into a focused writing environment. Inspired by iA Writer, Typora, and Bear.
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
- `src/zen-mode.js` — ZenMode class (~150 lines)
|
||||||
|
- `src/styles-zen.css` — Zen mode specific styles (~120 lines)
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
- `src/renderer.js` — Initialize ZenMode, register F11 shortcut, add View > Zen Mode menu
|
||||||
|
- `src/editor/codemirror-setup.js` — Export typewriter + dimming extensions
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
**Toggle**: F11, View > Zen Mode, command palette "Toggle Zen Mode"
|
||||||
|
**Exit**: Escape key, F11 again
|
||||||
|
|
||||||
|
**What hides**:
|
||||||
|
- Tab bar
|
||||||
|
- Toolbar
|
||||||
|
- Sidebar (collapsed)
|
||||||
|
- Status bar
|
||||||
|
- App header
|
||||||
|
|
||||||
|
**What shows**:
|
||||||
|
- Editor (full viewport)
|
||||||
|
- Floating HUD (bottom-center, semi-transparent)
|
||||||
|
|
||||||
|
### Floating HUD
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 847 words • ~4 min • 23:45 session │
|
||||||
|
│ ████████████████░░░░ 85% of 1000 │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Word count (from existing status bar logic)
|
||||||
|
- Estimated reading time (~200 wpm)
|
||||||
|
- Session timer (starts when zen mode activates)
|
||||||
|
- Optional progress bar toward word goal
|
||||||
|
|
||||||
|
### CodeMirror Extensions
|
||||||
|
|
||||||
|
**Typewriter Scroll** (`ViewPlugin`):
|
||||||
|
- Listens to `EditorView.update` for selection changes
|
||||||
|
- Calls `editor.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })`
|
||||||
|
- Smooth scrolling with `scrollBehavior: 'smooth'` in CSS
|
||||||
|
|
||||||
|
**Line Dimming** (`ViewPlugin` + `Decoration`):
|
||||||
|
- Builds a `DecorationSet` mapping each line to an opacity value
|
||||||
|
- Active line: opacity 1.0
|
||||||
|
- 1-2 lines away: 0.7
|
||||||
|
- 3-4 lines away: 0.5
|
||||||
|
- 5+ lines away: 0.3
|
||||||
|
- Uses `Decoration.line({ attributes: { style: 'opacity: X' } })`
|
||||||
|
|
||||||
|
### Centered Column
|
||||||
|
|
||||||
|
CSS applied to `.zen-mode .cm-content`:
|
||||||
|
```css
|
||||||
|
.zen-mode .cm-content {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- `this.previousState` stores which UI elements were visible before zen mode
|
||||||
|
- On exit, restores all elements to their previous visibility
|
||||||
|
- Editor content, cursor position, and scroll state are never modified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2: Document Outline Panel
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Provide always-visible heading navigation for documents of any length. The single most-requested navigation feature for multi-section documents.
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
- `src/sidebar/outline-panel.js` — `renderOutlinePanel` function (~100 lines)
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
- `src/index.html` — Add outline icon button in sidebar icons strip
|
||||||
|
- `src/renderer.js` — Register 'outline' panel, provide editor reference
|
||||||
|
|
||||||
|
### Sidebar Integration
|
||||||
|
|
||||||
|
Uses existing `SidebarManager.registerPanel()` API:
|
||||||
|
```javascript
|
||||||
|
sidebarManager.registerPanel('outline', {
|
||||||
|
title: 'Outline',
|
||||||
|
render: (container) => renderOutlinePanel(container, editor, editorContent)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
New icon button in sidebar strip (after templates icon):
|
||||||
|
```html
|
||||||
|
<button class="sidebar-icon" data-panel="outline" title="Outline (Ctrl+Shift+O)">
|
||||||
|
<!-- hierarchy/list icon SVG -->
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parsing Logic
|
||||||
|
|
||||||
|
Parse headings from raw markdown content using regex:
|
||||||
|
```javascript
|
||||||
|
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns array of:
|
||||||
|
```javascript
|
||||||
|
{ level: 1-6, text: "Heading Text", line: 42 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Debounced at 300ms to avoid re-parsing on every keystroke.
|
||||||
|
|
||||||
|
### UI Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ OUTLINE ☰ │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ ▸ Introduction (H1) │
|
||||||
|
│ ▸ Getting Started (H2) │
|
||||||
|
│ ▸ Prerequisites (H2) │
|
||||||
|
│ ▸ Node.js (H3) ◄ │
|
||||||
|
│ ▸ Installation (H2) │
|
||||||
|
│ ▸ Features (H1) │
|
||||||
|
│ ▸ Editor (H2) │
|
||||||
|
│ ▸ Export (H2) │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ 9 headings • 2 H1 • 4 H2 • 3 H3 │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Indentation based on heading level (H1 = 0px, H2 = 16px, H3 = 32px, etc.)
|
||||||
|
- Current heading highlighted with accent color (◄ indicator)
|
||||||
|
- Hover shows full heading text if truncated
|
||||||
|
|
||||||
|
### Click-to-Navigate
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
editor.dispatch({
|
||||||
|
effects: EditorView.scrollIntoView(linePos, { y: 'center' })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Brief highlight animation on the target line (fades out over 500ms).
|
||||||
|
|
||||||
|
### Current Heading Sync
|
||||||
|
|
||||||
|
On editor update (debounced 100ms):
|
||||||
|
1. Get cursor line number
|
||||||
|
2. Find the last heading whose line number <= cursor line
|
||||||
|
3. Set that heading as active in the outline
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
|
||||||
|
When no headings found:
|
||||||
|
```
|
||||||
|
No headings found
|
||||||
|
|
||||||
|
Use # to create headings:
|
||||||
|
# Heading 1
|
||||||
|
## Heading 2
|
||||||
|
### Heading 3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 3: Writing Analytics
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
Give writers real-time insight into their document's readability, structure, and vocabulary. This is the "surprise" feature most Markdown editors lack.
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
- `src/analytics/writing-analytics.js` — `WritingAnalytics` class (~180 lines)
|
||||||
|
- `src/analytics/analytics-panel.js` — `renderAnalyticsPanel` function (~120 lines)
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
- `src/renderer.js` — Register Ctrl+Shift+A shortcut, command palette entry, View menu item
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
- Keyboard: `Ctrl+Shift+A`
|
||||||
|
- Command Palette: "Show Writing Analytics"
|
||||||
|
- Menu: View > Writing Analytics
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
|
||||||
|
Uses existing `ModalManager` to show a modal overlay with analytics dashboard.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Writing Analytics ✕ │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─ Readability ──────────────────────────────────┐ │
|
||||||
|
│ │ Flesch Reading Ease: 67.3 (Standard) ○ │ │
|
||||||
|
│ │ Grade Level: 8.2 ○○○●○ │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Timing ───────────────────────────────────────┐ │
|
||||||
|
│ │ Reading Time: ~4 min │ │
|
||||||
|
│ │ Speaking Time: ~6 min │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Structure ────────────────────────────────────┐ │
|
||||||
|
│ │ Sentences: 42 • Paragraphs: 8 │ │
|
||||||
|
│ │ Avg Sentence: 14.2 words │ │
|
||||||
|
│ │ Longest: 38 words ("The quick brown fox...") │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Vocabulary ───────────────────────────────────┐ │
|
||||||
|
│ │ Unique: 312 / 847 words (36.8%) │ │
|
||||||
|
│ │ Top: the(42) and(31) markdown(28) ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Word Goal ────────────────────────────────────┐ │
|
||||||
|
│ │ Target: [1000] words │ │
|
||||||
|
│ │ ████████████████░░░░ 847/1000 (85%) │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics Implementation
|
||||||
|
|
||||||
|
**Readability (Flesch-Kincaid):**
|
||||||
|
```javascript
|
||||||
|
// Flesch Reading Ease
|
||||||
|
ease = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
|
||||||
|
|
||||||
|
// Flesch-Kincaid Grade Level
|
||||||
|
grade = 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Syllable Estimation:**
|
||||||
|
```javascript
|
||||||
|
function countSyllables(word) {
|
||||||
|
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||||
|
word = word.replace(/^y/, '');
|
||||||
|
return word.match(/[aeiouy]{1,2}/g)?.length || 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reading/Speaking Time:**
|
||||||
|
- Reading: 200 words/minute
|
||||||
|
- Speaking: 130 words/minute
|
||||||
|
|
||||||
|
**Lexical Diversity:**
|
||||||
|
- Ratio of unique words to total words (excluding stop words)
|
||||||
|
|
||||||
|
**Top Words:**
|
||||||
|
- Frequency map, sorted descending, top 10
|
||||||
|
- Excludes common stop words (the, a, an, is, are, etc.)
|
||||||
|
|
||||||
|
### Word Goal
|
||||||
|
|
||||||
|
- Persisted in `electron-store` per document (or global default)
|
||||||
|
- Progress bar with percentage
|
||||||
|
- Celebration effect when goal is reached (brief confetti animation or green flash)
|
||||||
|
|
||||||
|
### Update Cadence
|
||||||
|
|
||||||
|
- Re-analyzes on editor content change (debounced at 1000ms)
|
||||||
|
- If modal is open, updates live
|
||||||
|
- If modal is closed, no computation (zero overhead)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `src/zen-mode.js` | Create | ZenMode class with CM6 extensions |
|
||||||
|
| `src/styles-zen.css` | Create | Zen mode styling |
|
||||||
|
| `src/sidebar/outline-panel.js` | Create | Outline sidebar panel |
|
||||||
|
| `src/analytics/writing-analytics.js` | Create | Analytics computation engine |
|
||||||
|
| `src/analytics/analytics-panel.js` | Create | Analytics modal UI |
|
||||||
|
| `src/index.html` | Modify | Add outline icon, zen mode button |
|
||||||
|
| `src/renderer.js` | Modify | Initialize all three features |
|
||||||
|
| `src/editor/codemirror-setup.js` | Modify | Export typewriter + dimming extensions |
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Feature | Action |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| F11 | Zen Mode | Toggle on/off |
|
||||||
|
| Escape | Zen Mode | Exit (when active) |
|
||||||
|
| Ctrl+Shift+O | Outline | Open outline sidebar panel |
|
||||||
|
| Ctrl+Shift+A | Analytics | Open analytics modal |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
No new npm dependencies required. All features use:
|
||||||
|
- Existing CodeMirror 6 APIs (ViewPlugin, Decoration, scrollIntoView)
|
||||||
|
- Existing SidebarManager API
|
||||||
|
- Existing ModalManager API
|
||||||
|
- Pure JavaScript math for analytics
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,426 @@
|
|||||||
|
# MarkdownConverter v5.0 — Platform Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-14
|
||||||
|
**Status:** Approved
|
||||||
|
**Author:** Amit Haridas
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Transform MarkdownConverter from a monolithic editor into an extensible platform with a plugin system and three feature packs, shipped together as v5.0.
|
||||||
|
|
||||||
|
## Subsystems
|
||||||
|
|
||||||
|
1. **Plugin System** — Lightweight plugin registry with extension points (sidebar, commands, settings, status bar, export hooks, event bus)
|
||||||
|
2. **Writing Studio Plugin** — Manuscript manager, goal tracking, writing sprints, snapshots, smart proofreading
|
||||||
|
3. **AI Assistant Plugin** — Multi-provider AI writing assistant (Ollama, LMStudio, GGUF direct with GPU, Anthropic, OpenAI)
|
||||||
|
4. **Collaboration Plugin** — Git-based async collaboration, comments/annotations, review requests
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**Existing functionality is never replaced or broken.** The plugin system is additive. All existing keyboard shortcuts, features, and UI remain untouched. Plugin shortcuts use `Ctrl+Alt+` namespace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Plugin System
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
plugins/
|
||||||
|
plugin-registry.js # Load, register, lifecycle
|
||||||
|
plugin-api.js # Base class plugins extend
|
||||||
|
plugin-loader.js # Discovers and validates manifests
|
||||||
|
built-in/
|
||||||
|
writing-studio/
|
||||||
|
manifest.json
|
||||||
|
index.js
|
||||||
|
panels/
|
||||||
|
components/
|
||||||
|
ai-assistant/
|
||||||
|
manifest.json
|
||||||
|
index.js
|
||||||
|
providers/
|
||||||
|
collaboration/
|
||||||
|
manifest.json
|
||||||
|
index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manifest Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "writing-studio",
|
||||||
|
"name": "Writing Studio",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Manuscript management, goal tracking, writing sprints",
|
||||||
|
"icon": "pen-tool",
|
||||||
|
"extensionPoints": {
|
||||||
|
"sidebar": { "panel": "panels/manuscript-panel.js", "order": 30 },
|
||||||
|
"settings": { "section": "settings/index.js" },
|
||||||
|
"statusBar": { "indicators": ["sprint-timer", "word-goal"] },
|
||||||
|
"commands": [
|
||||||
|
{ "id": "start-sprint", "label": "Start Writing Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||||
|
{ "id": "take-snapshot", "label": "Take Snapshot", "shortcut": "Ctrl+Alt+N" }
|
||||||
|
],
|
||||||
|
"exportHooks": {
|
||||||
|
"preExport": "hooks/pre-export.js",
|
||||||
|
"postExport": "hooks/post-export.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": [
|
||||||
|
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||||
|
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin Lifecycle
|
||||||
|
|
||||||
|
1. PluginLoader discovers manifests in `built-in/` + user plugins directory
|
||||||
|
2. PluginRegistry validates manifests
|
||||||
|
3. Each plugin calls `Plugin.init(context)` receiving scoped API context
|
||||||
|
4. Extension points registered (sidebar panels, commands, status bar items)
|
||||||
|
5. Plugins activate lazily — sidebar panel loads JS when user clicks tab
|
||||||
|
|
||||||
|
### Plugin Context API
|
||||||
|
|
||||||
|
Each plugin's `init()` receives:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
sidebar: {
|
||||||
|
registerPanel(id, { icon, title, component })
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
register(id, label, handler, shortcut?)
|
||||||
|
},
|
||||||
|
statusBar: {
|
||||||
|
registerIndicator(id, { position, render })
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
get(key), // plugin-scoped
|
||||||
|
set(key, value), // auto-persisted via electron-store
|
||||||
|
onChanged(key, callback)
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
getContent(), // current document
|
||||||
|
getSelection(), // selected text
|
||||||
|
insertAtCursor(text), // requires opt-in
|
||||||
|
onContentChanged(callback)
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
on(event, handler),
|
||||||
|
emit(event, data)
|
||||||
|
},
|
||||||
|
exports: {
|
||||||
|
registerPreHook(handler),
|
||||||
|
registerPostHook(handler)
|
||||||
|
},
|
||||||
|
ipc: {
|
||||||
|
invoke(channel, ...args),
|
||||||
|
on(channel, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Bus Events
|
||||||
|
|
||||||
|
Each event has a versioned payload schema. Breaking changes increment the version suffix.
|
||||||
|
|
||||||
|
```
|
||||||
|
document:opened → { filePath: string, tabId: string }
|
||||||
|
document:saved → { filePath: string, tabId: string }
|
||||||
|
document:changed → { tabId: string, content: string, wordCount: number }
|
||||||
|
editor:selection-changed → { tabId: string, text: string, from: {line,ch}, to: {line,ch} }
|
||||||
|
tab:switched → { tabId: string, filePath: string }
|
||||||
|
tab:closed → { tabId: string, filePath: string }
|
||||||
|
export:started → { format: string, filePath: string }
|
||||||
|
export:completed → { format: string, filePath: string, outputPath: string }
|
||||||
|
export:failed → { format: string, error: string }
|
||||||
|
plugin:loaded → { pluginId: string, version: string }
|
||||||
|
plugin:activated → { pluginId: string }
|
||||||
|
plugin:deactivated → { pluginId: string }
|
||||||
|
app:ready → {}
|
||||||
|
app:before-quit → {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Rules
|
||||||
|
|
||||||
|
- Built-in plugins use the same API as future third-party plugins
|
||||||
|
- Lazy activation — sidebar panels don't load until clicked
|
||||||
|
- Scoped settings: `plugins.<id>.<key>` in electron-store
|
||||||
|
- Plugin commands globally unique — registry rejects duplicate command IDs at load time
|
||||||
|
- **Plugin sandboxing**: each plugin handler is wrapped in try/catch. For CPU-intensive operations (AI inference, diff computation), plugins must delegate to main process via IPC. Handlers that block the renderer for >5s trigger a warning notification. Memory-hungry operations (GGUF inference) run in isolated child processes.
|
||||||
|
- **Cross-plugin graceful degradation**: plugins check `context.events.hasHandler('ai:analyze')` before emitting cross-plugin requests. If no handler (AI plugin disabled), show a "this feature requires the AI plugin" prompt instead of failing silently. All cross-plugin calls have a 30s timeout with default fallback behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Writing Studio Plugin
|
||||||
|
|
||||||
|
### 2A. Manuscript / Project Manager
|
||||||
|
|
||||||
|
Folder-based project structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Manuscripts/
|
||||||
|
my-novel/
|
||||||
|
.project.json # { title, targets, metadata }
|
||||||
|
01-chapter-one.md
|
||||||
|
02-chapter-two.md
|
||||||
|
characters/
|
||||||
|
protagonist.md
|
||||||
|
research/
|
||||||
|
world-building.md
|
||||||
|
.snapshots/
|
||||||
|
2026-04-14T10-30.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`.project.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "My Novel",
|
||||||
|
"type": "manuscript",
|
||||||
|
"target": { "words": 80000, "deadline": "2026-09-01" },
|
||||||
|
"chapters": [
|
||||||
|
{ "file": "01-chapter-one.md", "title": "The Beginning", "status": "draft" }
|
||||||
|
],
|
||||||
|
"metadata": { "author": "", "genre": "", "synopsis": "" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidebar panel shows project tree with drag-to-reorder, word counts per chapter, target progress bar. "Compile manuscript" exports all chapters as a single document.
|
||||||
|
|
||||||
|
### 2B. Goal Tracking & Writing Sprints
|
||||||
|
|
||||||
|
- **Status bar**: daily progress bar + sprint timer
|
||||||
|
- **Writing sprint**: configurable duration (15/25/30/45/60 min), word count delta, WPM at end
|
||||||
|
- **Goal tracking**: daily/weekly word goals, streak tracking, 30-day bar chart
|
||||||
|
- **Enhanced analytics**: session tracking, readability scores, productive time-of-day heatmap
|
||||||
|
- Data stored in `plugins.writing-studio.history` as date-keyed map
|
||||||
|
|
||||||
|
### 2C. Snapshot & Versioning
|
||||||
|
|
||||||
|
- `Ctrl+Alt+N` or toolbar button saves snapshot
|
||||||
|
- Stored as JSON: `{ timestamp, content, wordCount, cursorPos, label }`
|
||||||
|
- Snapshot panel in sidebar: Restore, Diff (side-by-side), auto-snapshot interval
|
||||||
|
- Snapshots in `.snapshots/` inside project folder, or app data if no project
|
||||||
|
|
||||||
|
### 2D. Smart Proofreading
|
||||||
|
|
||||||
|
Delegates to AI plugin via event bus. Writing Studio provides:
|
||||||
|
- Right-click context menu: "Check grammar", "Suggest alternatives", "Analyze readability"
|
||||||
|
- Inline wavy underline decorations for issues
|
||||||
|
- Proofread panel: issues categorized by type with Accept/Dismiss
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Shortcut | Action |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| `start-sprint` | `Ctrl+Alt+S` | Start writing sprint |
|
||||||
|
| `stop-sprint` | `Ctrl+Alt+Shift+S` | Stop sprint |
|
||||||
|
| `take-snapshot` | `Ctrl+Alt+N` | Save snapshot |
|
||||||
|
| `restore-last-snapshot` | `Ctrl+Alt+Z` | Restore latest snapshot |
|
||||||
|
| `new-project` | — | Create manuscript project |
|
||||||
|
| `compile-manuscript` | `Ctrl+Alt+E` | Export all chapters |
|
||||||
|
| `proofread-document` | `Ctrl+Alt+G` | AI proofread |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. AI Assistant Plugin
|
||||||
|
|
||||||
|
### Provider Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Plugin
|
||||||
|
├── Provider Interface
|
||||||
|
│ ├── complete(prompt, options) → string
|
||||||
|
│ ├── stream(prompt, options) → AsyncIterable
|
||||||
|
│ └── analyze(text, type) → AnalysisResult
|
||||||
|
│
|
||||||
|
├── Providers
|
||||||
|
│ ├── OllamaProvider — localhost:11434
|
||||||
|
│ ├── LMStudioProvider — localhost:1234/v1
|
||||||
|
│ ├── GGUFProvider — direct llama.cpp with GPU support
|
||||||
|
│ ├── AnthropicProvider — Claude API
|
||||||
|
│ └── OpenAIProvider — GPT API
|
||||||
|
│
|
||||||
|
└── Features
|
||||||
|
├── Grammar/style check
|
||||||
|
├── Inline auto-complete
|
||||||
|
├── AI chat panel (sidebar)
|
||||||
|
├── Document analysis
|
||||||
|
└── Smart commands (command palette)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Details
|
||||||
|
|
||||||
|
**Ollama:** `GET /api/tags` for models, `POST /api/generate` and `POST /api/chat` for inference.
|
||||||
|
|
||||||
|
**LMStudio:** OpenAI-compatible API at `localhost:1234/v1`. `GET /v1/models`, standard chat completion format, SSE streaming.
|
||||||
|
|
||||||
|
**GGUF Direct (with GPU):**
|
||||||
|
- Ships bundled llama.cpp binaries per platform (CUDA, Vulkan, Metal, CPU variants)
|
||||||
|
- Auto-detects GPU: CUDA (nvidia-smi), Vulkan driver, Metal (macOS)
|
||||||
|
- GPU layer offloading: configurable, auto-suggests based on VRAM vs model size
|
||||||
|
- Settings: GPU backend selection, layer count, context length, thread count
|
||||||
|
- "Keep model loaded" option for faster repeated requests
|
||||||
|
- WASM fallback for sandboxed environments (CPU-only)
|
||||||
|
- External binary path for advanced users with custom builds
|
||||||
|
- **Process isolation**: llama.cpp runs as a spawned child process (not in main process). If it crashes, detected via exit handler, auto-restarted with notification. GPU memory freed on crash. App remains stable.
|
||||||
|
- Process management: spawn in server mode on localhost ephemeral port, clean up on app quit or model unload
|
||||||
|
|
||||||
|
**Cloud (Anthropic/OpenAI):**
|
||||||
|
- API key stored encrypted via electron safeStorage
|
||||||
|
- Token usage tracking with estimated cost
|
||||||
|
- Rate limit awareness with request queueing and backoff
|
||||||
|
|
||||||
|
### IPC Design
|
||||||
|
|
||||||
|
All provider HTTP requests go through main process:
|
||||||
|
- No CORS issues
|
||||||
|
- API keys never in renderer
|
||||||
|
- Main process enforces rate limiting
|
||||||
|
- GGUF inference in isolated child process
|
||||||
|
|
||||||
|
**Request/response lifecycle:**
|
||||||
|
```
|
||||||
|
Renderer → ipc.invoke('ai:complete') → Main → HTTP to provider → result → Renderer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Streaming lifecycle with error handling:**
|
||||||
|
```
|
||||||
|
Renderer → ipc.invoke('ai:stream', { requestId, prompt })
|
||||||
|
← Main assigns requestId, returns { requestId }
|
||||||
|
← ipc.on('ai:chunk', { requestId, text }) — repeated
|
||||||
|
← ipc.on('ai:done', { requestId }) — success
|
||||||
|
← ipc.on('ai:error', { requestId, error }) — failure
|
||||||
|
|
||||||
|
// Cancellation
|
||||||
|
Renderer → ipc.invoke('ai:cancel', { requestId })
|
||||||
|
← Main aborts HTTP request, emits 'ai:done'
|
||||||
|
|
||||||
|
// Orphan cleanup: if renderer disconnects (crash/close),
|
||||||
|
// main process detects via 'render-view-deleted' and aborts all active streams.
|
||||||
|
// Heartbeat: if no chunk received in 30s, main emits 'ai:error' with timeout.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
1. **Inline suggestions**: ghost text after configurable delay, Tab to accept, Esc to dismiss
|
||||||
|
2. **AI chat panel**: sidebar conversation, "Insert" / "Replace selection" buttons
|
||||||
|
3. **Document analysis**: grammar, style, tone, with accept/reject per suggestion
|
||||||
|
4. **Smart commands**: summarize, generate outline, find inconsistencies, translate, explain code
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
|
||||||
|
- Local-first: default provider is Ollama
|
||||||
|
- No telemetry: requests go direct to provider
|
||||||
|
- Content gating: exclude file types from AI
|
||||||
|
- Status bar shows "AI: processing..." with cancel option
|
||||||
|
- Cloud usage stats in settings (tokens, cost)
|
||||||
|
|
||||||
|
### Cross-Plugin Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Writing Studio calls AI Plugin
|
||||||
|
context.events.emit('ai:analyze', { text, type: 'grammar', callback });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Collaboration Plugin
|
||||||
|
|
||||||
|
### 4A. Enhanced Git Panel
|
||||||
|
|
||||||
|
Upgrades to existing git panel:
|
||||||
|
- Remote management (add/remove remotes, push/pull)
|
||||||
|
- Branch list and switching
|
||||||
|
- Commit history with diff viewer (side-by-side or unified)
|
||||||
|
- Conflict resolution UI (accept-ours/accept-theirs/per-edit)
|
||||||
|
|
||||||
|
### 4B. Shared Repository Workflow
|
||||||
|
|
||||||
|
1. Writer A creates project + initializes git + pushes to shared repo
|
||||||
|
2. Writer B clones repo from within MarkdownConverter
|
||||||
|
3. Both write on their own branches
|
||||||
|
4. Writer A creates review request (simplified PR)
|
||||||
|
|
||||||
|
Review request: changed files, word count diff, commit messages. Reviewer can approve, request changes, leave inline comments. Reviews are git branches + comments as git notes.
|
||||||
|
|
||||||
|
### 4C. Comments & Annotations
|
||||||
|
|
||||||
|
Inline comments stored as JSON in `.comments/` directory (git-tracked):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"file": "03-chapter-three.md",
|
||||||
|
"anchor": {
|
||||||
|
"contextBefore": "The hero looked at the horizon and said,",
|
||||||
|
"selectedText": "I will not go quietly into that dark night",
|
||||||
|
"contextAfter": "He turned to face the army alone."
|
||||||
|
},
|
||||||
|
"line": 142,
|
||||||
|
"text": "This dialogue feels unnatural",
|
||||||
|
"author": "amit",
|
||||||
|
"timestamp": "2026-04-14T14:30:00Z",
|
||||||
|
"replies": [],
|
||||||
|
"resolved": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Anchor-based positioning**: comments store `contextBefore`, `selectedText`, and `contextAfter` (not absolute byte offsets). On file change, re-anchor by searching for the context text. If context no longer matches, mark comment as "detached" and show a warning. Falls back to `line` number as rough position.
|
||||||
|
- Highlighted text in editor with tooltip on hover
|
||||||
|
- Comment panel in sidebar: all unresolved comments across files
|
||||||
|
- Resolution workflow: add → address → reply → resolve
|
||||||
|
- Resolved comments dim but stay visible
|
||||||
|
|
||||||
|
### 4D. Change Notifications
|
||||||
|
|
||||||
|
- Status bar indicator: `↓ 3 new commits`
|
||||||
|
- Click to see changes, one-click pull
|
||||||
|
- Conflicts trigger resolution UI
|
||||||
|
- Push button only when local commits ahead of remote
|
||||||
|
|
||||||
|
### 4E. Offline-First
|
||||||
|
|
||||||
|
All writing happens locally. Git is the sync mechanism. No internet required for writing, commenting, snapshots, or sprints. Push/pull on user action or auto-sync setting.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
| Command | Shortcut | Action |
|
||||||
|
|---------|----------|--------|
|
||||||
|
| `collab:commit` | `Ctrl+Shift+G` | Commit with message |
|
||||||
|
| `collab:push` | — | Push current branch |
|
||||||
|
| `collab:pull` | — | Pull from remote |
|
||||||
|
| `collab:add-comment` | `Ctrl+Alt+C` | Comment on selection |
|
||||||
|
| `collab:next-comment` | `F8` | Next unresolved comment |
|
||||||
|
| `collab:prev-comment` | `Shift+F8` | Previous comment |
|
||||||
|
| `collab:create-review` | — | Create review request |
|
||||||
|
|
||||||
|
### Cross-Plugin Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
context.events.emit('snapshot:created', { file, snapshotId });
|
||||||
|
context.events.on('project:chapter-opened', (chapter) => { /* load comments */ });
|
||||||
|
context.events.on('comment:added', (comment) => { /* AI could suggest fix */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle Size Impact
|
||||||
|
|
||||||
|
- llama.cpp binaries: ~15MB per GPU variant. Only target platform shipped. GPU variants (CUDA/Vulkan/Metal) downloaded on demand if user enables GGUF direct loading — not bundled by default. Only CPU fallback bundled (~15MB).
|
||||||
|
- Plugin system core: ~30KB
|
||||||
|
- Each built-in plugin: ~50-100KB
|
||||||
|
- Diff library (jsdiff): ~15KB
|
||||||
|
- Total estimated increase: ~20-30MB (core), additional ~30-50MB per GPU variant (lazy download)
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Plugin system: unit tests for registry, loader, context API mocking
|
||||||
|
- Each plugin: isolated unit tests, integration tests via plugin context
|
||||||
|
- AI provider tests: mock HTTP responses, test streaming parsing
|
||||||
|
- Git tests: use test repository fixture
|
||||||
|
- E2E: verify plugin loading doesn't break existing features
|
||||||
@@ -75,6 +75,7 @@ module.exports = [
|
|||||||
jest: 'readonly',
|
jest: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
test: 'readonly',
|
test: 'readonly',
|
||||||
|
it: 'readonly',
|
||||||
expect: 'readonly',
|
expect: 'readonly',
|
||||||
beforeEach: 'readonly',
|
beforeEach: 'readonly',
|
||||||
afterEach: 'readonly',
|
afterEach: 'readonly',
|
||||||
|
|||||||
Generated
+13547
File diff suppressed because it is too large
Load Diff
+33
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.1.0",
|
"version": "4.4.5",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,13 +14,16 @@
|
|||||||
"format:check": "prettier --check src tests",
|
"format:check": "prettier --check src tests",
|
||||||
"build": "electron-builder",
|
"build": "electron-builder",
|
||||||
"build:win": "electron-builder --win",
|
"build:win": "electron-builder --win",
|
||||||
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx CSC_KEY_PASSWORD=%CSC_KEY_PASSWORD% electron-builder --win",
|
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win",
|
||||||
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
|
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
|
||||||
|
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
||||||
"build:mac": "electron-builder --mac",
|
"build:mac": "electron-builder --mac",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --linux",
|
||||||
|
"build:linux-ci": "electron-builder --linux deb AppImage",
|
||||||
"build:local": "electron-builder --linux --win",
|
"build:local": "electron-builder --linux --win",
|
||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder -mwl",
|
||||||
|
"download-tools": "node scripts/download-tools.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js"
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -42,7 +45,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^37.4.0",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -70,9 +73,10 @@
|
|||||||
"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",
|
||||||
"docx4js": "^3.3.0",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
@@ -89,11 +93,14 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"nth-check": "^2.1.1",
|
"nth-check": "^2.1.1",
|
||||||
"lodash.pick": "npm:lodash@^4.17.21"
|
"lodash.pick": "npm:lodash@^4.17.21",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.concreteinfo.markdownconverter",
|
"appId": "com.concreteinfo.markdownconverter",
|
||||||
"productName": "MarkdownConverter",
|
"productName": "MarkdownConverter",
|
||||||
|
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
@@ -105,6 +112,10 @@
|
|||||||
"node_modules/**/*",
|
"node_modules/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/ffmpeg-static/**"
|
||||||
|
],
|
||||||
|
"extraFiles": [],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
"ext": "md",
|
"ext": "md",
|
||||||
@@ -155,7 +166,15 @@
|
|||||||
],
|
],
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"requestedExecutionLevel": "asInvoker",
|
"requestedExecutionLevel": "asInvoker",
|
||||||
"signAndEditExecutable": false
|
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||||
|
"verifyUpdateCodeSignature": false,
|
||||||
|
"signAndEditExecutable": false,
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "bin/win32/pandoc.exe",
|
||||||
|
"to": "bin/pandoc.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -177,29 +196,24 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"deb",
|
"deb",
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"snap",
|
"snap"
|
||||||
"rpm"
|
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "bin/linux/pandoc",
|
||||||
|
"to": "bin/pandoc"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [
|
"depends": [
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"imagemagick",
|
"imagemagick",
|
||||||
"libreoffice-common"
|
"libreoffice-common"
|
||||||
],
|
],
|
||||||
"description": "Professional Markdown editor and universal file converter",
|
"description": "Professional Markdown editor and universal file converter",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||||
},
|
|
||||||
"rpm": {
|
|
||||||
"depends": [
|
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"ImageMagick",
|
|
||||||
"libreoffice-core"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+604
@@ -0,0 +1,604 @@
|
|||||||
|
# Repository Structure Map
|
||||||
|
|
||||||
|
Auto-generated by `~/.claude-shared/scripts/repo-map.sh` (universal-ctags).
|
||||||
|
Signatures only — classes, functions, methods, interfaces, enums, types, namespaces, traits.
|
||||||
|
Regenerate after structural changes. Languages: `JavaScript,Sh`.
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/download-tools.js:
|
||||||
|
L22 method extract (PANDOC_CONFIG.linux)
|
||||||
|
L36 method extract (PANDOC_CONFIG.win32)
|
||||||
|
L51 method extract (PANDOC_CONFIG.darwin)
|
||||||
|
L63 function download
|
||||||
|
L70 function get (download)
|
||||||
|
L109 function downloadPandoc
|
||||||
|
|
||||||
|
scripts/generate-icons.js:
|
||||||
|
L17 function generateIcons
|
||||||
|
|
||||||
|
src/adapters/electron/fs.js:
|
||||||
|
L20 method readFile (electronFsAdapter)
|
||||||
|
L30 method writeFile (electronFsAdapter)
|
||||||
|
L39 method deleteFile (electronFsAdapter)
|
||||||
|
L48 method ensureDir (electronFsAdapter)
|
||||||
|
L57 method listDirectory (electronFsAdapter)
|
||||||
|
L77 method exists (electronFsAdapter)
|
||||||
|
L86 method isDirectory (electronFsAdapter)
|
||||||
|
L96 method copy (electronFsAdapter)
|
||||||
|
L106 method move (electronFsAdapter)
|
||||||
|
|
||||||
|
src/analytics/analytics-panel.js:
|
||||||
|
L7 function showAnalyticsModal
|
||||||
|
L105 function escHandler (showAnalyticsModal)
|
||||||
|
L116 function escapeHtml
|
||||||
|
|
||||||
|
src/analytics/writing-analytics.js:
|
||||||
|
L77 function countSyllables
|
||||||
|
L83 function extractWords
|
||||||
|
L87 function getReadabilityLabel
|
||||||
|
L95 function analyze
|
||||||
|
|
||||||
|
src/command-palette.js:
|
||||||
|
L1 class CommandPalette
|
||||||
|
L2 method constructor (CommandPalette)
|
||||||
|
L12 method register (CommandPalette)
|
||||||
|
L16 method open (CommandPalette)
|
||||||
|
L24 method close (CommandPalette)
|
||||||
|
L28 method isOpen (CommandPalette)
|
||||||
|
L32 method setupEventListeners (CommandPalette)
|
||||||
|
L61 method renderResults (CommandPalette)
|
||||||
|
L90 method highlightMatch (CommandPalette)
|
||||||
|
L96 method updateSelection (CommandPalette)
|
||||||
|
L105 method executeSelected (CommandPalette)
|
||||||
|
|
||||||
|
src/editor/codemirror-setup.js:
|
||||||
|
L46 function createEditor
|
||||||
|
L61 function onChange (createEditor)
|
||||||
|
L118 function getLanguageExtension
|
||||||
|
L120 method javascript (getLanguageExtension.loaders)
|
||||||
|
L124 method html (getLanguageExtension.loaders)
|
||||||
|
L128 method css (getLanguageExtension.loaders)
|
||||||
|
L132 method json (getLanguageExtension.loaders)
|
||||||
|
L136 method python (getLanguageExtension.loaders)
|
||||||
|
|
||||||
|
src/main.js:
|
||||||
|
L18 function getPandocPath
|
||||||
|
L36 function getFFmpegPath
|
||||||
|
L55 function sanitizeErrorMessage
|
||||||
|
L64 function createRateLimiter
|
||||||
|
L66 function canProceed (createRateLimiter)
|
||||||
|
L83 function validatePath
|
||||||
|
L136 function resolveWritablePath
|
||||||
|
L181 function isPathAccessible
|
||||||
|
L203 function convertDataToMarkdown
|
||||||
|
L225 function runPandocCmd
|
||||||
|
L247 function parseCommand
|
||||||
|
L282 method get (store)
|
||||||
|
L291 method set (store)
|
||||||
|
L488 function checkPandocAvailability
|
||||||
|
L500 function createWindow
|
||||||
|
L586 function buildRecentFilesMenu
|
||||||
|
L599 method click (buildRecentFilesMenu.anonymousObjectc5643c890c05)
|
||||||
|
L637 function getRecentFiles
|
||||||
|
L647 function createMenu
|
||||||
|
L872 method click (createMenu.anonymousObjectc5643c890e05.anonymousObjectc5643c894505)
|
||||||
|
L1118 method click (createMenu.anonymousObjectc5643c895205.anonymousObjectc5643c897e05)
|
||||||
|
L1279 method click (createMenu.anonymousObjectc5643c899305.anonymousObjectc5643c89a505)
|
||||||
|
L1351 function showAboutDialog
|
||||||
|
L1452 function showDependenciesDialog
|
||||||
|
L1564 function openPDFFile
|
||||||
|
L1583 function openFile
|
||||||
|
L1622 function openPdfFile
|
||||||
|
L1645 function saveAsFile
|
||||||
|
L1664 function exportFile
|
||||||
|
L1673 function showExportOptionsDialog
|
||||||
|
L1676 function showBatchConversionDialog
|
||||||
|
L1681 function selectWordTemplate
|
||||||
|
L1705 function showTemplateSettings
|
||||||
|
L1902 function processDynamicFields
|
||||||
|
L1921 function setDocxPageSize
|
||||||
|
L1980 function addHeaderFooterToDocx
|
||||||
|
L2111 function exportWordWithTemplate
|
||||||
|
L2153 function exportPDFViaWordTemplate
|
||||||
|
L2231 function showUniversalConverterDialog
|
||||||
|
L2236 function showPDFEditorDialog
|
||||||
|
L2246 function checkConverterAvailable
|
||||||
|
L2370 function collectFiles
|
||||||
|
L2464 function convertWithLibreOffice
|
||||||
|
L2498 function convertWithImageMagick
|
||||||
|
L2507 function convertWithFFmpeg
|
||||||
|
L2515 function convertWithPandoc
|
||||||
|
L2521 function performExportWithOptions
|
||||||
|
L2805 function tryPdfFallback
|
||||||
|
L2882 function showExportSuccess
|
||||||
|
L2892 function exportWithPandoc
|
||||||
|
L2964 function exportToHTML
|
||||||
|
L3080 function exportToPDFElectron
|
||||||
|
L3224 function exportSpreadsheet
|
||||||
|
L3233 function importDocument
|
||||||
|
L3355 function setTheme
|
||||||
|
L3536 function extractTablesFromMarkdown
|
||||||
|
L3574 function performBatchConversion
|
||||||
|
L3597 function findMarkdownFiles (performBatchConversion)
|
||||||
|
L3636 function processNextFile (performBatchConversion)
|
||||||
|
L3970 function handleCLIConversion
|
||||||
|
L3997 function showConversionDialog
|
||||||
|
L4064 function performCLIConversion
|
||||||
|
L4098 function buildPandocCommand
|
||||||
|
L4319 function openFileFromPath
|
||||||
|
L4394 function openAsciiGenerator
|
||||||
|
L4426 function openTableGenerator
|
||||||
|
L4724 function loadSnippets
|
||||||
|
L4734 function saveSnippetsFile
|
||||||
|
|
||||||
|
src/main/GitOperations.js:
|
||||||
|
L3 function getGitInstance
|
||||||
|
L7 function getStatus
|
||||||
|
L16 function stage
|
||||||
|
L26 function commit
|
||||||
|
L35 function log
|
||||||
|
|
||||||
|
src/main/PDFOperations.js:
|
||||||
|
L5 function parsePageRanges
|
||||||
|
L28 function hexToRgb
|
||||||
|
L39 function pdfMerge
|
||||||
|
L59 function pdfSplit
|
||||||
|
L126 function pdfCompress
|
||||||
|
L152 function pdfRotate
|
||||||
|
L182 function pdfDeletePages
|
||||||
|
L208 function pdfReorder
|
||||||
|
L233 function pdfWatermark
|
||||||
|
L319 function pdfEncrypt
|
||||||
|
L353 function pdfDecrypt
|
||||||
|
L370 function pdfSetPermissions
|
||||||
|
L404 function executeOperation
|
||||||
|
L431 function getPageCount
|
||||||
|
|
||||||
|
src/plugins/built-in/_sample/index.js:
|
||||||
|
L3 class SamplePlugin
|
||||||
|
L4 method init (SamplePlugin)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/goal-tracker.js:
|
||||||
|
L3 class GoalTracker
|
||||||
|
L7 method constructor (GoalTracker)
|
||||||
|
L11 method _getHistory (GoalTracker)
|
||||||
|
L16 method _setHistory (GoalTracker)
|
||||||
|
L20 method _setHistoryDay (GoalTracker)
|
||||||
|
L26 method addWords (GoalTracker)
|
||||||
|
L37 method getDailyProgress (GoalTracker)
|
||||||
|
L44 method getStreak (GoalTracker)
|
||||||
|
L61 method getLast30Days (GoalTracker)
|
||||||
|
L74 method getWeeklyTotal (GoalTracker)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/index.js:
|
||||||
|
L7 class WritingStudioPlugin
|
||||||
|
L8 method init (WritingStudioPlugin)
|
||||||
|
L34 method _registerCommands (WritingStudioPlugin)
|
||||||
|
L112 method _registerStatusBar (WritingStudioPlugin)
|
||||||
|
L123 method deactivate (WritingStudioPlugin)
|
||||||
|
L127 method getEngines (WritingStudioPlugin)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/goals-panel.js:
|
||||||
|
L1 function renderGoalsPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/manuscript-panel.js:
|
||||||
|
L1 function renderManuscriptPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/proofread-panel.js:
|
||||||
|
L1 function renderProofreadPanel
|
||||||
|
L41 method callback (anonymousObject39a0f0110105)
|
||||||
|
L50 function renderIssues
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/panels/snapshots-panel.js:
|
||||||
|
L1 function renderSnapshotsPanel
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/project-manager.js:
|
||||||
|
L1 class ProjectManager
|
||||||
|
L5 method constructor (ProjectManager)
|
||||||
|
L9 method createProject (ProjectManager)
|
||||||
|
L21 method loadProject (ProjectManager)
|
||||||
|
L27 method _saveProject (ProjectManager)
|
||||||
|
L31 method addChapter (ProjectManager)
|
||||||
|
L38 method updateChapter (ProjectManager)
|
||||||
|
L45 method compileManuscript (ProjectManager)
|
||||||
|
L56 method getStats (ProjectManager)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/snapshot-manager.js:
|
||||||
|
L1 class SnapshotManager
|
||||||
|
L6 method constructor (SnapshotManager)
|
||||||
|
L11 method _getAll (SnapshotManager)
|
||||||
|
L16 method _saveAll (SnapshotManager)
|
||||||
|
L20 method create (SnapshotManager)
|
||||||
|
L34 method list (SnapshotManager)
|
||||||
|
L38 method getById (SnapshotManager)
|
||||||
|
L42 method restore (SnapshotManager)
|
||||||
|
L48 method delete (SnapshotManager)
|
||||||
|
L53 method diff (SnapshotManager)
|
||||||
|
L71 method prune (SnapshotManager)
|
||||||
|
|
||||||
|
src/plugins/built-in/writing-studio/sprint-engine.js:
|
||||||
|
L1 class SprintEngine
|
||||||
|
L6 method constructor (SprintEngine)
|
||||||
|
L14 method start (SprintEngine)
|
||||||
|
L22 method stop (SprintEngine)
|
||||||
|
L33 method tick (SprintEngine)
|
||||||
|
L43 method isActive (SprintEngine)
|
||||||
|
L47 method getRemaining (SprintEngine)
|
||||||
|
|
||||||
|
src/plugins/event-bus.js:
|
||||||
|
L1 class EventBus
|
||||||
|
L2 method constructor (EventBus)
|
||||||
|
L6 method on (EventBus)
|
||||||
|
L13 method off (EventBus)
|
||||||
|
L25 method emit (EventBus)
|
||||||
|
L37 method hasHandler (EventBus)
|
||||||
|
|
||||||
|
src/plugins/plugin-api.js:
|
||||||
|
L1 class PluginAPI
|
||||||
|
L7 method init (PluginAPI)
|
||||||
|
L12 method activate (PluginAPI)
|
||||||
|
L15 method deactivate (PluginAPI)
|
||||||
|
L18 method getManifest (PluginAPI)
|
||||||
|
|
||||||
|
src/plugins/plugin-context.js:
|
||||||
|
L1 class PluginContext
|
||||||
|
L14 method constructor (PluginContext)
|
||||||
|
L23 method register (PluginContext.constructor.commands)
|
||||||
|
L24 function safeHandler (PluginContext.constructor.commands.register)
|
||||||
|
L65 method registerPreHook (PluginContext.constructor.exports)
|
||||||
|
L68 method registerPostHook (PluginContext.constructor.exports)
|
||||||
|
|
||||||
|
src/plugins/plugin-loader.js:
|
||||||
|
L4 class PluginLoader
|
||||||
|
L8 method constructor (PluginLoader)
|
||||||
|
L17 method discoverPlugins (PluginLoader)
|
||||||
|
L66 method validateManifest (PluginLoader)
|
||||||
|
|
||||||
|
src/plugins/plugin-registry.js:
|
||||||
|
L4 class PluginRegistry
|
||||||
|
L5 method constructor (PluginRegistry)
|
||||||
|
L15 method register (PluginRegistry)
|
||||||
|
L49 method getPlugin (PluginRegistry)
|
||||||
|
L53 method getAll (PluginRegistry)
|
||||||
|
L57 method activate (PluginRegistry)
|
||||||
|
L68 method deactivate (PluginRegistry)
|
||||||
|
|
||||||
|
src/plugins/settings-store.js:
|
||||||
|
L1 class SettingsStore
|
||||||
|
L5 method constructor (SettingsStore)
|
||||||
|
L9 method get (SettingsStore)
|
||||||
|
L13 method set (SettingsStore)
|
||||||
|
L17 method onChanged (SettingsStore)
|
||||||
|
|
||||||
|
src/preload.js:
|
||||||
|
L257 method send (anonymousObjectb0724fcb0105)
|
||||||
|
L271 method invoke (anonymousObjectb0724fcb0105)
|
||||||
|
L290 method on (anonymousObjectb0724fcb0105)
|
||||||
|
L292 function subscription (anonymousObjectb0724fcb0105.on)
|
||||||
|
L310 method once (anonymousObjectb0724fcb0105)
|
||||||
|
L322 method removeAllListeners (anonymousObjectb0724fcb0105)
|
||||||
|
|
||||||
|
src/print-preview.js:
|
||||||
|
L1 class PrintPreview
|
||||||
|
L2 method constructor (PrintPreview)
|
||||||
|
L9 method open (PrintPreview)
|
||||||
|
L20 method close (PrintPreview)
|
||||||
|
L28 method setupEventListeners (PrintPreview)
|
||||||
|
L53 method updateScaleLabel (PrintPreview)
|
||||||
|
L59 method updatePreview (PrintPreview)
|
||||||
|
L111 method refreshPreview (PrintPreview)
|
||||||
|
L117 method getOptions (PrintPreview)
|
||||||
|
L130 method executePrint (PrintPreview)
|
||||||
|
|
||||||
|
src/renderer.js:
|
||||||
|
L19 method send (window.electronAPI)
|
||||||
|
L22 method invoke (window.electronAPI)
|
||||||
|
L25 method on (window.electronAPI)
|
||||||
|
L26 function subscription (window.electronAPI.on)
|
||||||
|
L32 method once (window.electronAPI)
|
||||||
|
L35 method removeAllListeners (window.electronAPI)
|
||||||
|
L81 function getSidebarManager
|
||||||
|
L85 function getRenderTemplatesPanel
|
||||||
|
L90 function getRenderExplorerPanel
|
||||||
|
L95 function getRenderGitPanel
|
||||||
|
L99 function getRenderSnippetsPanel
|
||||||
|
L105 function getRenderOutlinePanel
|
||||||
|
L110 function getReplPanel
|
||||||
|
L114 function getCommandPalette
|
||||||
|
L118 function getPrintPreview
|
||||||
|
L122 function getCreateWelcomeContent
|
||||||
|
L127 function getZenMode
|
||||||
|
L132 function getShowAnalyticsModal
|
||||||
|
L137 function ensureToastContainer
|
||||||
|
L147 function notifyUser
|
||||||
|
L159 function dismiss (notifyUser)
|
||||||
|
L173 method highlight (anonymousObject71555c7b0405)
|
||||||
|
L202 method start (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L205 method tokenizer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L221 method renderer (anonymousObject71555c7b0705.anonymousObject71555c7b0805)
|
||||||
|
L241 function plantumlEncode (notifyUser)
|
||||||
|
L249 function scopeCSS (notifyUser)
|
||||||
|
L271 class TabManager (notifyUser)
|
||||||
|
L272 method constructor (notifyUser.TabManager)
|
||||||
|
L306 method setupEventListeners (notifyUser.TabManager)
|
||||||
|
L354 method createNewTab (notifyUser.TabManager)
|
||||||
|
L379 method createPdfTab (notifyUser.TabManager)
|
||||||
|
L405 method createPdfTabElements (notifyUser.TabManager)
|
||||||
|
L436 method setupPdfTabEvents (notifyUser.TabManager)
|
||||||
|
L503 method loadPdfInTab (notifyUser.TabManager)
|
||||||
|
L528 method renderPdfPageInTab (notifyUser.TabManager)
|
||||||
|
L555 method createTabElements (notifyUser.TabManager)
|
||||||
|
L580 method onChange (anonymousObject71555c7b0d05)
|
||||||
|
L602 method onUpdate (anonymousObject71555c7b0d05)
|
||||||
|
L614 method switchToTab (notifyUser.TabManager)
|
||||||
|
L641 method switchToNextTab (notifyUser.TabManager)
|
||||||
|
L647 method closeTab (notifyUser.TabManager)
|
||||||
|
L692 method updateTabBar (notifyUser.TabManager)
|
||||||
|
L718 method updateUI (notifyUser.TabManager)
|
||||||
|
L748 method saveCurrentTabState (notifyUser.TabManager)
|
||||||
|
L756 method restoreTabState (notifyUser.TabManager)
|
||||||
|
L765 method focusActiveEditor (notifyUser.TabManager)
|
||||||
|
L771 method updatePreview (notifyUser.TabManager)
|
||||||
|
L800 method _hash (notifyUser.TabManager)
|
||||||
|
L809 method _renderPreview (notifyUser.TabManager)
|
||||||
|
L963 function onerror (img)
|
||||||
|
L997 method updatePreviewVisibility (notifyUser.TabManager)
|
||||||
|
L1010 method updateLineNumbers (notifyUser.TabManager)
|
||||||
|
L1015 method updateWordCount (notifyUser.TabManager)
|
||||||
|
L1031 method updateCursorPosition (notifyUser.TabManager)
|
||||||
|
L1038 method updateFilePath (notifyUser.TabManager)
|
||||||
|
L1046 method updateBreadcrumb (notifyUser.TabManager)
|
||||||
|
L1054 method setupEditorEvents (notifyUser.TabManager)
|
||||||
|
L1058 method handleEditorInput (notifyUser.TabManager)
|
||||||
|
L1073 method startAutoSave (notifyUser.TabManager)
|
||||||
|
L1081 method stopAutoSave (notifyUser.TabManager)
|
||||||
|
L1087 method performAutoSave (notifyUser.TabManager)
|
||||||
|
L1103 method showAutoSaveIndicator (notifyUser.TabManager)
|
||||||
|
L1119 method addToRecentFiles (notifyUser.TabManager)
|
||||||
|
L1135 method getRecentFiles (notifyUser.TabManager)
|
||||||
|
L1145 method setupToolbarEvents (notifyUser.TabManager)
|
||||||
|
L1215 method wrapSelection (notifyUser.TabManager)
|
||||||
|
L1233 method insertAtLineStart (notifyUser.TabManager)
|
||||||
|
L1249 method insertTable (notifyUser.TabManager)
|
||||||
|
L1259 method insertCodeBlock (notifyUser.TabManager)
|
||||||
|
L1277 method insertHorizontalRule (notifyUser.TabManager)
|
||||||
|
L1280 method setupFindEvents (notifyUser.TabManager)
|
||||||
|
L1350 method performFind (notifyUser.TabManager)
|
||||||
|
L1384 method findNext (notifyUser.TabManager)
|
||||||
|
L1390 method findPrevious (notifyUser.TabManager)
|
||||||
|
L1399 method highlightMatch (notifyUser.TabManager)
|
||||||
|
L1430 method replaceOne (notifyUser.TabManager)
|
||||||
|
L1454 method replaceAll (notifyUser.TabManager)
|
||||||
|
L1485 method clearFindHighlights (notifyUser.TabManager)
|
||||||
|
L1492 method checkForLargeFile (notifyUser.TabManager)
|
||||||
|
L1508 method openFile (notifyUser.TabManager)
|
||||||
|
L1555 method getEditorContent (notifyUser.TabManager)
|
||||||
|
L1564 method setEditorContent (notifyUser.TabManager)
|
||||||
|
L1579 method insertAtCursor (notifyUser.TabManager)
|
||||||
|
L1594 method getSelection (notifyUser.TabManager)
|
||||||
|
L1602 method replaceSelection (notifyUser.TabManager)
|
||||||
|
L1616 method getCurrentContent (notifyUser.TabManager)
|
||||||
|
L1620 method getCurrentFilePath (notifyUser.TabManager)
|
||||||
|
L1735 method render (anonymousObject71555c7b2d05)
|
||||||
|
L1788 method registerIndicator (anonymousObject71555c7b3105.statusBar)
|
||||||
|
L1881 function onload (reader)
|
||||||
|
L2032 method onChange (anonymousObject71555c7b3405)
|
||||||
|
L2040 method onUpdate (anonymousObject71555c7b3405)
|
||||||
|
L2164 function applyCustomPreviewCSS (notifyUser)
|
||||||
|
L2174 function triggerLoadCustomCSS (notifyUser)
|
||||||
|
L2188 function triggerClearCustomCSS (notifyUser)
|
||||||
|
L2202 function updateFontSizes (notifyUser)
|
||||||
|
L2234 function openPrintPreviewDialog (notifyUser)
|
||||||
|
L2255 function showExportDialog (notifyUser)
|
||||||
|
L2269 function hideExportDialog (notifyUser)
|
||||||
|
L2273 function initializeExportForm (notifyUser)
|
||||||
|
L2343 function collectExportOptions (notifyUser)
|
||||||
|
L2444 function loadExportProfiles (notifyUser)
|
||||||
|
L2456 function saveExportProfiles (notifyUser)
|
||||||
|
L2459 function populateProfileDropdown (notifyUser)
|
||||||
|
L2476 function saveCurrentProfile (notifyUser)
|
||||||
|
L2511 function loadProfile (notifyUser)
|
||||||
|
L2545 function deleteSelectedProfile (notifyUser)
|
||||||
|
L2679 function onchange (input)
|
||||||
|
L2776 function showBatchDialog (notifyUser)
|
||||||
|
L2796 function hideBatchDialog (notifyUser)
|
||||||
|
L2799 function updateBatchProgress (notifyUser)
|
||||||
|
L2816 function validateBatchForm (notifyUser)
|
||||||
|
L3375 function showUniversalConverterDialog (notifyUser)
|
||||||
|
L3383 function updateConverterFormats (notifyUser)
|
||||||
|
L3423 function updateConverterAdvancedOptions (notifyUser)
|
||||||
|
L3434 function collectConverterAdvancedOptions (notifyUser)
|
||||||
|
L3645 function showPDFEditorDialog (notifyUser)
|
||||||
|
L3769 function hidePDFEditorDialog (notifyUser)
|
||||||
|
L3780 function updateMergeFilesList (notifyUser)
|
||||||
|
L4107 function getPDFStatusElement (notifyUser)
|
||||||
|
L4110 function showPDFStatus (notifyUser)
|
||||||
|
L4117 function clearPDFStatus (notifyUser)
|
||||||
|
L4124 function showPDFValidationMessage (notifyUser)
|
||||||
|
L4135 function processPDFOperation (notifyUser)
|
||||||
|
L4379 function initMathSupport (notifyUser)
|
||||||
|
L4391 function onload (katexJS)
|
||||||
|
L4396 function onload (autoRenderJS)
|
||||||
|
L4417 function openHeaderFooterDialog (notifyUser)
|
||||||
|
L4425 function closeHeaderFooterDialog (notifyUser)
|
||||||
|
L4430 function openFieldPickerDialog (notifyUser)
|
||||||
|
L4436 function closeFieldPickerDialog (notifyUser)
|
||||||
|
L4477 function toggleConfigContent (notifyUser)
|
||||||
|
L4488 function saveHeaderFooterSettings (notifyUser)
|
||||||
|
L4509 function browseForLogo (notifyUser)
|
||||||
|
L4519 function clearLogo (notifyUser)
|
||||||
|
L4528 function insertDynamicField (notifyUser)
|
||||||
|
L4594 function showTableGenerator (notifyUser)
|
||||||
|
L4605 function hideTableGenerator (notifyUser)
|
||||||
|
L4608 function generateTablePreview (notifyUser)
|
||||||
|
L4616 function generateMarkdownTable (notifyUser)
|
||||||
|
L4685 function insertGeneratedTable (notifyUser)
|
||||||
|
L4742 function showASCIIGenerator (notifyUser)
|
||||||
|
L4753 function hideASCIIGenerator (notifyUser)
|
||||||
|
L4756 function switchASCIIMode (notifyUser)
|
||||||
|
L4784 function generateASCIIPreview (notifyUser)
|
||||||
|
L4802 function textToASCII (notifyUser)
|
||||||
|
L5238 function createASCIIBox (notifyUser)
|
||||||
|
L5312 function getASCIITemplate (notifyUser)
|
||||||
|
L5469 function loadASCIITemplate (notifyUser)
|
||||||
|
L5473 function insertASCIIArt (notifyUser)
|
||||||
|
L5523 function anonymousFunction71555c7bd300
|
||||||
|
L5603 function getPdfjsLib (notifyUser)
|
||||||
|
L5612 function openPdfFile (notifyUser)
|
||||||
|
L5627 function renderPdfPage (notifyUser)
|
||||||
|
L5733 function closePdfViewer (notifyUser)
|
||||||
|
L5783 function openPdfEditorDialog (notifyUser)
|
||||||
|
L5831 function initPaneResizer (notifyUser)
|
||||||
|
L5920 function onPDFFileSelected (notifyUser)
|
||||||
|
L5935 function loadPDFThumbnails (notifyUser)
|
||||||
|
L5966 function renderThumbnailGrid (notifyUser)
|
||||||
|
L6060 function renderThumbnail (notifyUser)
|
||||||
|
L6085 function syncRotateInput (notifyUser)
|
||||||
|
L6094 function syncDeleteInput (notifyUser)
|
||||||
|
L6103 function syncReorderInput (notifyUser)
|
||||||
|
|
||||||
|
src/repl/repl-panel.js:
|
||||||
|
L1 class ReplPanel
|
||||||
|
L2 method constructor (ReplPanel)
|
||||||
|
L8 method setupEventListeners (ReplPanel)
|
||||||
|
L13 method toggle (ReplPanel)
|
||||||
|
L19 method show (ReplPanel)
|
||||||
|
L25 method clear (ReplPanel)
|
||||||
|
L29 method appendOutput (ReplPanel)
|
||||||
|
L42 method escapeHtml (ReplPanel)
|
||||||
|
|
||||||
|
src/sidebar/explorer-panel.js:
|
||||||
|
L1 function renderExplorerPanel
|
||||||
|
L40 function renderTree
|
||||||
|
L79 function getFileIcon
|
||||||
|
|
||||||
|
src/sidebar/git-panel.js:
|
||||||
|
L1 function renderGitPanel
|
||||||
|
L24 function loadGitStatus (renderGitPanel)
|
||||||
|
|
||||||
|
src/sidebar/outline-panel.js:
|
||||||
|
L6 function renderOutlinePanel
|
||||||
|
L18 function parseHeadings (renderOutlinePanel)
|
||||||
|
L36 function findActiveHeading (renderOutlinePanel)
|
||||||
|
L48 function renderHeadings (renderOutlinePanel)
|
||||||
|
L89 function escapeHtml (renderOutlinePanel)
|
||||||
|
L95 function refresh (renderOutlinePanel)
|
||||||
|
L100 function setActiveHeading (renderOutlinePanel)
|
||||||
|
|
||||||
|
src/sidebar/sidebar-manager.js:
|
||||||
|
L1 class SidebarManager
|
||||||
|
L2 method constructor (SidebarManager)
|
||||||
|
L11 method setupEventListeners (SidebarManager)
|
||||||
|
L20 method registerPanel (SidebarManager)
|
||||||
|
L24 method togglePanel (SidebarManager)
|
||||||
|
L32 method expand (SidebarManager)
|
||||||
|
L45 method collapse (SidebarManager)
|
||||||
|
|
||||||
|
src/sidebar/snippets-panel.js:
|
||||||
|
L1 function renderSnippetsPanel
|
||||||
|
L14 function loadSnippets (renderSnippetsPanel)
|
||||||
|
L19 function renderList (renderSnippetsPanel)
|
||||||
|
|
||||||
|
src/sidebar/templates-panel.js:
|
||||||
|
L18 function renderTemplatesPanel
|
||||||
|
|
||||||
|
src/utils/ModalManager.js:
|
||||||
|
L5 class ModalManager
|
||||||
|
L16 method constructor (ModalManager)
|
||||||
|
L31 method init (ModalManager)
|
||||||
|
L47 method setupCloseTriggers (ModalManager)
|
||||||
|
L51 function handler (ModalManager.setupCloseTriggers)
|
||||||
|
L65 function handler
|
||||||
|
L74 method getFocusableElements (ModalManager)
|
||||||
|
L89 method trapFocus (ModalManager)
|
||||||
|
L111 method handleKeydown (ModalManager)
|
||||||
|
L119 method open (ModalManager)
|
||||||
|
L141 function keydownHandler (ModalManager.open)
|
||||||
|
L164 method close (ModalManager)
|
||||||
|
L182 function addHidden (ModalManager.close)
|
||||||
|
L188 function onTransitionEnd (ModalManager.close)
|
||||||
|
L223 method isOpen (ModalManager)
|
||||||
|
L227 method destroy (ModalManager)
|
||||||
|
|
||||||
|
src/welcome.js:
|
||||||
|
L1 function createWelcomeContent
|
||||||
|
|
||||||
|
src/wordTemplateExporter.js:
|
||||||
|
L11 class WordTemplateExporter
|
||||||
|
L12 method constructor (WordTemplateExporter)
|
||||||
|
L24 method preprocessMarkdownForWordExport (WordTemplateExporter)
|
||||||
|
L33 function flush (WordTemplateExporter.preprocessMarkdownForWordExport)
|
||||||
|
L59 function stripArtifacts (WordTemplateExporter.preprocessMarkdownForWordExport)
|
||||||
|
|
||||||
|
src/zen-mode.js:
|
||||||
|
L10 class ZenMode
|
||||||
|
L14 method constructor (ZenMode)
|
||||||
|
L23 method activate (ZenMode)
|
||||||
|
L65 method deactivate (ZenMode)
|
||||||
|
L101 method toggle (ZenMode)
|
||||||
|
L109 method _applyTypewriterBehavior (ZenMode)
|
||||||
|
L111 function scrollFn (ZenMode._applyTypewriterBehavior)
|
||||||
|
L151 method _createHUD (ZenMode)
|
||||||
|
L169 method _updateHUD (ZenMode)
|
||||||
|
L220 method constructor (anonymousObject3acf7ce30205)
|
||||||
|
L240 function getOpacity
|
||||||
|
L250 method constructor (anonymousObject3acf7ce30405)
|
||||||
|
|
||||||
|
tests/git-operations.test.js:
|
||||||
|
L26 function asyncFn
|
||||||
|
L35 function asyncFnWithError
|
||||||
|
|
||||||
|
tests/main-utils.test.js:
|
||||||
|
L7 function sanitizeErrorMessage
|
||||||
|
L49 function createRateLimiter
|
||||||
|
L51 function canProceed (createRateLimiter)
|
||||||
|
|
||||||
|
tests/markdown-extensions.test.js:
|
||||||
|
L83 method start (extension)
|
||||||
|
L86 method tokenizer (extension)
|
||||||
|
L102 method renderer (extension)
|
||||||
|
L151 function plantumlEncode
|
||||||
|
L176 function slugify
|
||||||
|
L203 function scopeCSS
|
||||||
|
|
||||||
|
tests/modal-manager.test.js:
|
||||||
|
L11 function createModalElement
|
||||||
|
|
||||||
|
tests/plugin-api.test.js:
|
||||||
|
L18 class MyPlugin
|
||||||
|
L19 method init (MyPlugin)
|
||||||
|
|
||||||
|
tests/plugin-context.test.js:
|
||||||
|
L39 function badHandler
|
||||||
|
|
||||||
|
tests/plugin-loader.test.js:
|
||||||
|
L17 function writeManifest
|
||||||
|
|
||||||
|
tests/plugin-registry.test.js:
|
||||||
|
L5 class TestPlugin
|
||||||
|
L6 method init (TestPlugin)
|
||||||
|
L10 method activate (TestPlugin)
|
||||||
|
L13 method deactivate (TestPlugin)
|
||||||
|
L69 class BadPlugin
|
||||||
|
L70 method init (BadPlugin)
|
||||||
|
|
||||||
|
tests/setup.js:
|
||||||
|
L96 function error (console)
|
||||||
|
|
||||||
|
tests/sidebar.test.js:
|
||||||
|
L41 method render (anonymousObjectfe2d35d70105)
|
||||||
|
L52 method render (anonymousObjectfe2d35d70205)
|
||||||
|
L62 method render (anonymousObjectfe2d35d70305)
|
||||||
|
L68 method render (anonymousObjectfe2d35d70405)
|
||||||
|
L80 method render (anonymousObjectfe2d35d70505)
|
||||||
|
L88 method render (anonymousObjectfe2d35d70605)
|
||||||
|
L96 method render (anonymousObjectfe2d35d70705)
|
||||||
|
L120 method render (anonymousObjectfe2d35d70905)
|
||||||
|
L130 method render (anonymousObjectfe2d35d70a05)
|
||||||
|
|
||||||
|
tests/utils.test.js:
|
||||||
|
L9 function parseCommand
|
||||||
|
L78 function hexToRgb
|
||||||
|
L121 function getExtension
|
||||||
|
L133 function replaceExtension
|
||||||
|
```
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# create-selfsigned-cert.ps1
|
||||||
|
# Generates a self-signed code-signing certificate for local/development builds.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1
|
||||||
|
# npm run create-cert
|
||||||
|
#
|
||||||
|
# Then build with:
|
||||||
|
# $env:CSC_LINK="code-signing-cert.pfx"; $env:CSC_KEY_PASSWORD="YourPassword"; npm run build:win-signed
|
||||||
|
#
|
||||||
|
# NOTE: Self-signed certificates will still show a SmartScreen warning for end users.
|
||||||
|
# For production releases, obtain an OV or EV certificate from a trusted CA
|
||||||
|
# (DigiCert, Sectigo, Certum, etc.). EV certificates bypass SmartScreen immediately.
|
||||||
|
# Open-source projects can apply for free signing at https://signpath.io/
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$CertPassword = "MarkdownConverter2025",
|
||||||
|
[string]$OutputFile = "code-signing-cert.pfx",
|
||||||
|
[string]$Subject = "CN=ConcreteInfo, O=ConcreteInfo, L=India, C=IN"
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "Creating self-signed code-signing certificate..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Create the certificate in the current user's certificate store
|
||||||
|
$cert = New-SelfSignedCertificate `
|
||||||
|
-Type CodeSigningCert `
|
||||||
|
-Subject $Subject `
|
||||||
|
-CertStoreLocation "Cert:\CurrentUser\My" `
|
||||||
|
-NotAfter (Get-Date).AddYears(3) `
|
||||||
|
-HashAlgorithm SHA256 `
|
||||||
|
-KeyLength 4096 `
|
||||||
|
-KeyUsage DigitalSignature
|
||||||
|
|
||||||
|
if (-not $cert) {
|
||||||
|
Write-Error "Failed to create certificate."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Certificate created: $($cert.Thumbprint)" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Export to PFX
|
||||||
|
$securePassword = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText
|
||||||
|
$exportPath = Join-Path (Get-Location) $OutputFile
|
||||||
|
|
||||||
|
Export-PfxCertificate -Cert $cert -FilePath $exportPath -Password $securePassword | Out-Null
|
||||||
|
|
||||||
|
if (Test-Path $exportPath) {
|
||||||
|
Write-Host "Certificate exported to: $exportPath" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To build a signed release:" -ForegroundColor Yellow
|
||||||
|
Write-Host ' $env:CSC_LINK="code-signing-cert.pfx"' -ForegroundColor White
|
||||||
|
Write-Host " `$env:CSC_KEY_PASSWORD=`"$CertPassword`"" -ForegroundColor White
|
||||||
|
Write-Host " npm run build:win-signed" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "IMPORTANT: Add code-signing-cert.pfx to .gitignore!" -ForegroundColor Red
|
||||||
|
} else {
|
||||||
|
Write-Error "Export failed."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Downloads pandoc binary for the current build platform.
|
||||||
|
* Run automatically via `npm run download-tools` before building.
|
||||||
|
* Skips download if binary already exists (idempotent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const PANDOC_VERSION = '3.9.0.2';
|
||||||
|
|
||||||
|
const PANDOC_CONFIG = {
|
||||||
|
linux: {
|
||||||
|
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
|
||||||
|
archiveExt: '.tar.gz',
|
||||||
|
destFile: 'pandoc',
|
||||||
|
extract(archivePath, destDir) {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}" pandoc-${PANDOC_VERSION}/bin/pandoc`);
|
||||||
|
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||||
|
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||||
|
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
win32: {
|
||||||
|
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-windows-x86_64.zip`,
|
||||||
|
archiveExt: '.zip',
|
||||||
|
destFile: 'pandoc.exe',
|
||||||
|
extract(archivePath, destDir) {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
execSync(
|
||||||
|
`powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`,
|
||||||
|
);
|
||||||
|
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'pandoc.exe');
|
||||||
|
fs.copyFileSync(src, path.join(destDir, 'pandoc.exe'));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
darwin: {
|
||||||
|
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-x86_64-macOS.zip`,
|
||||||
|
archiveExt: '.zip',
|
||||||
|
destFile: 'pandoc',
|
||||||
|
extract(archivePath, destDir) {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
|
||||||
|
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc');
|
||||||
|
fs.copyFileSync(src, path.join(destDir, 'pandoc'));
|
||||||
|
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function download(url, destPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
let received = 0;
|
||||||
|
let total = 0;
|
||||||
|
let lastPct = -1;
|
||||||
|
|
||||||
|
function get(redirectUrl) {
|
||||||
|
const client = redirectUrl.startsWith('https://') ? https : http;
|
||||||
|
client
|
||||||
|
.get(redirectUrl, (res) => {
|
||||||
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||||
|
get(res.headers.location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode} for ${redirectUrl}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
total = parseInt(res.headers['content-length'] || '0', 10);
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (total > 0) {
|
||||||
|
const pct = Math.floor((received / total) * 100);
|
||||||
|
if (pct !== lastPct && pct % 10 === 0) {
|
||||||
|
process.stdout.write(` ${pct}%\r`);
|
||||||
|
lastPct = pct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
process.stdout.write(' 100%\n');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
fs.unlink(destPath, () => {});
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
get(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPandoc() {
|
||||||
|
const platform = process.platform;
|
||||||
|
const config = PANDOC_CONFIG[platform];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.log(`[download-tools] No pandoc config for platform "${platform}" — skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destDir = path.join(__dirname, '..', 'bin', platform);
|
||||||
|
const destFile = path.join(destDir, config.destFile);
|
||||||
|
|
||||||
|
if (fs.existsSync(destFile)) {
|
||||||
|
console.log(`[download-tools] pandoc already present at ${destFile} — skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
|
||||||
|
const tmpArchive = path.join(os.tmpdir(), `pandoc-download${config.archiveExt}`);
|
||||||
|
|
||||||
|
console.log(`[download-tools] Downloading pandoc ${PANDOC_VERSION} for ${platform}...`);
|
||||||
|
await download(config.url, tmpArchive);
|
||||||
|
|
||||||
|
console.log(`[download-tools] Extracting to ${destDir}...`);
|
||||||
|
config.extract(tmpArchive, destDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(tmpArchive);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[download-tools] pandoc ready: ${destFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadPandoc().catch((err) => {
|
||||||
|
console.error('[download-tools] FAILED:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+22
-18
@@ -4,7 +4,7 @@
|
|||||||
* Implements file system operations for Electron using IPC.
|
* Implements file system operations for Electron using IPC.
|
||||||
* This abstracts file operations to enable easier testing and migration.
|
* This abstracts file operations to enable easier testing and migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,7 +18,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<string>} File content
|
* @returns {Promise<string>} File content
|
||||||
*/
|
*/
|
||||||
async readFile(path) {
|
async readFile(path) {
|
||||||
return await window.electronAPI.readFile(path);
|
return await window.electronAPI.file.read(path);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +28,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async writeFile(path, content) {
|
async writeFile(path, content) {
|
||||||
return await window.electronAPI.writeFile(path, content);
|
return await window.electronAPI.file.write(path, content);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,8 +37,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async deleteFile(path) {
|
async deleteFile(path) {
|
||||||
// TODO: Add IPC channel for delete
|
return await window.electronAPI.file.delete(path);
|
||||||
throw new Error('deleteFile not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,8 +46,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async ensureDir(path) {
|
async ensureDir(path) {
|
||||||
// TODO: Add IPC channel for ensureDir
|
return await window.electronAPI.file.ensureDir(path);
|
||||||
throw new Error('ensureDir not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,8 +55,18 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<Array<import('../types').FileInfo>>}
|
* @returns {Promise<Array<import('../types').FileInfo>>}
|
||||||
*/
|
*/
|
||||||
async listDirectory(path) {
|
async listDirectory(path) {
|
||||||
// TODO: Add IPC channel for listDirectory
|
const result = await window.electronAPI.invoke('list-directory', path);
|
||||||
throw new Error('listDirectory not implemented');
|
if (!result?.entries) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.entries.map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
isDir: entry.isDirectory,
|
||||||
|
size: entry.size ?? 0,
|
||||||
|
modified: entry.modified ?? 0,
|
||||||
|
path: entry.path,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,8 +75,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async exists(path) {
|
async exists(path) {
|
||||||
// TODO: Add IPC channel for exists
|
return await window.electronAPI.file.exists(path);
|
||||||
throw new Error('exists not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,8 +84,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
async isDirectory(path) {
|
async isDirectory(path) {
|
||||||
// TODO: Add IPC channel for isDirectory
|
return await window.electronAPI.file.isDirectory(path);
|
||||||
throw new Error('isDirectory not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,8 +94,7 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async copy(source, dest) {
|
async copy(source, dest) {
|
||||||
// TODO: Add IPC channel for copy
|
return await window.electronAPI.file.copy(source, dest);
|
||||||
throw new Error('copy not implemented');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,9 +104,8 @@ const electronFsAdapter = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async move(source, dest) {
|
async move(source, dest) {
|
||||||
// TODO: Add IPC channel for move
|
return await window.electronAPI.file.move(source, dest);
|
||||||
throw new Error('move not implemented');
|
},
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { electronFsAdapter };
|
module.exports = { electronFsAdapter };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Adapters abstract file system, conversion, and system operations
|
* Adapters abstract file system, conversion, and system operations
|
||||||
* to enable easier testing and future platform migration.
|
* to enable easier testing and future platform migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Writing Analytics Panel — modal overlay displaying analytics dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { analyze } = require('./writing-analytics');
|
||||||
|
|
||||||
|
function showAnalyticsModal(tabManager) {
|
||||||
|
const existing = document.getElementById('analytics-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const content = tabManager.getEditorContent();
|
||||||
|
const metrics = analyze(content);
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'analytics-modal';
|
||||||
|
overlay.className = 'analytics-overlay';
|
||||||
|
|
||||||
|
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="analytics-modal">
|
||||||
|
<div class="analytics-header">
|
||||||
|
<h2>Writing Analytics</h2>
|
||||||
|
<button class="analytics-close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-body">
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Readability</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Flesch Reading Ease</span>
|
||||||
|
<span class="analytics-value">${metrics.fleschEase}<small>${metrics.readabilityLabel}</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Grade Level</span>
|
||||||
|
<span class="analytics-value">${metrics.fleschGrade}</span>
|
||||||
|
</div>
|
||||||
|
<div class="readability-meter">
|
||||||
|
<div class="readability-fill" style="width: ${Math.max(0, Math.min(100, metrics.fleschEase))}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Timing</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Reading Time</span>
|
||||||
|
<span class="analytics-value">~${metrics.readingTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Speaking Time</span>
|
||||||
|
<span class="analytics-value">~${metrics.speakingTime} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Structure</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Sentences</span>
|
||||||
|
<span class="analytics-value">${metrics.sentenceCount} • Paragraphs: ${metrics.paragraphCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Avg Sentence</span>
|
||||||
|
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
metrics.longestSentenceLength > 0
|
||||||
|
? `
|
||||||
|
<div class="analytics-row analytics-longest">
|
||||||
|
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||||
|
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Vocabulary</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Unique</span>
|
||||||
|
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
metrics.topWords.length > 0
|
||||||
|
? `
|
||||||
|
<div class="word-cloud">
|
||||||
|
${metrics.topWords
|
||||||
|
.map((w) => {
|
||||||
|
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||||
|
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||||
|
})
|
||||||
|
.join('')}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeBtn = overlay.querySelector('.analytics-close');
|
||||||
|
closeBtn.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const escHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { showAnalyticsModal };
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Writing Analytics — pure computation engine
|
||||||
|
* No DOM dependencies. Exported analyze(text) returns a metrics object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
'the',
|
||||||
|
'a',
|
||||||
|
'an',
|
||||||
|
'is',
|
||||||
|
'are',
|
||||||
|
'was',
|
||||||
|
'were',
|
||||||
|
'be',
|
||||||
|
'been',
|
||||||
|
'have',
|
||||||
|
'has',
|
||||||
|
'had',
|
||||||
|
'do',
|
||||||
|
'does',
|
||||||
|
'did',
|
||||||
|
'will',
|
||||||
|
'would',
|
||||||
|
'could',
|
||||||
|
'should',
|
||||||
|
'to',
|
||||||
|
'of',
|
||||||
|
'in',
|
||||||
|
'for',
|
||||||
|
'on',
|
||||||
|
'with',
|
||||||
|
'at',
|
||||||
|
'by',
|
||||||
|
'from',
|
||||||
|
'as',
|
||||||
|
'and',
|
||||||
|
'or',
|
||||||
|
'but',
|
||||||
|
'if',
|
||||||
|
'it',
|
||||||
|
'its',
|
||||||
|
'this',
|
||||||
|
'that',
|
||||||
|
'these',
|
||||||
|
'those',
|
||||||
|
'i',
|
||||||
|
'me',
|
||||||
|
'my',
|
||||||
|
'we',
|
||||||
|
'our',
|
||||||
|
'you',
|
||||||
|
'your',
|
||||||
|
'he',
|
||||||
|
'him',
|
||||||
|
'his',
|
||||||
|
'she',
|
||||||
|
'her',
|
||||||
|
'they',
|
||||||
|
'them',
|
||||||
|
'their',
|
||||||
|
'not',
|
||||||
|
'no',
|
||||||
|
'so',
|
||||||
|
'than',
|
||||||
|
'too',
|
||||||
|
'very',
|
||||||
|
'also',
|
||||||
|
'just',
|
||||||
|
'about',
|
||||||
|
'up',
|
||||||
|
'out',
|
||||||
|
'what',
|
||||||
|
'which',
|
||||||
|
'who',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function countSyllables(word) {
|
||||||
|
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
|
||||||
|
word = word.replace(/^y/, '');
|
||||||
|
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWords(text) {
|
||||||
|
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadabilityLabel(score) {
|
||||||
|
if (score >= 90) return 'Very Easy';
|
||||||
|
if (score >= 70) return 'Easy';
|
||||||
|
if (score >= 50) return 'Standard';
|
||||||
|
if (score >= 30) return 'Difficult';
|
||||||
|
return 'Very Difficult';
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(text) {
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
return {
|
||||||
|
wordCount: 0,
|
||||||
|
sentenceCount: 0,
|
||||||
|
paragraphCount: 0,
|
||||||
|
fleschEase: 0,
|
||||||
|
fleschGrade: 0,
|
||||||
|
readabilityLabel: 'N/A',
|
||||||
|
readingTime: 0,
|
||||||
|
speakingTime: 0,
|
||||||
|
uniqueWordCount: 0,
|
||||||
|
lexicalDiversity: 0,
|
||||||
|
avgSentenceLength: 0,
|
||||||
|
longestSentence: '',
|
||||||
|
longestSentenceLength: 0,
|
||||||
|
topWords: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = extractWords(text);
|
||||||
|
const wordCount = words.length;
|
||||||
|
|
||||||
|
const sentences = text
|
||||||
|
.split(/[.!?]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const sentenceCount = Math.max(sentences.length, 1);
|
||||||
|
|
||||||
|
const paragraphs = text
|
||||||
|
.split(/\n\s*\n/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const paragraphCount = Math.max(paragraphs.length, 1);
|
||||||
|
|
||||||
|
let totalSyllables = 0;
|
||||||
|
for (const w of words) {
|
||||||
|
totalSyllables += countSyllables(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleschEase =
|
||||||
|
Math.round(
|
||||||
|
(206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10
|
||||||
|
) / 10;
|
||||||
|
const fleschGrade =
|
||||||
|
Math.round(
|
||||||
|
(0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10
|
||||||
|
) / 10;
|
||||||
|
const readabilityLabel = getReadabilityLabel(fleschEase);
|
||||||
|
|
||||||
|
const readingTime = Math.ceil(wordCount / 200);
|
||||||
|
const speakingTime = Math.ceil(wordCount / 130);
|
||||||
|
|
||||||
|
const uniqueWords = new Set(words.map((w) => w.toLowerCase()));
|
||||||
|
const uniqueWordCount = uniqueWords.size;
|
||||||
|
const lexicalDiversity =
|
||||||
|
wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
|
||||||
|
|
||||||
|
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
|
||||||
|
|
||||||
|
let longestSentence = '';
|
||||||
|
let longestSentenceLength = 0;
|
||||||
|
for (const s of sentences) {
|
||||||
|
const sWords = extractWords(s);
|
||||||
|
if (sWords.length > longestSentenceLength) {
|
||||||
|
longestSentenceLength = sWords.length;
|
||||||
|
longestSentence = s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (longestSentence.length > 80) {
|
||||||
|
longestSentence = longestSentence.substring(0, 80) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordFreq = {};
|
||||||
|
for (const w of words) {
|
||||||
|
const lower = w.toLowerCase();
|
||||||
|
if (!STOP_WORDS.has(lower) && lower.length > 1) {
|
||||||
|
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topWords = Object.entries(wordFreq)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([word, count]) => ({ word, count }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
wordCount,
|
||||||
|
sentenceCount,
|
||||||
|
paragraphCount,
|
||||||
|
fleschEase,
|
||||||
|
fleschGrade,
|
||||||
|
readabilityLabel,
|
||||||
|
readingTime,
|
||||||
|
speakingTime,
|
||||||
|
uniqueWordCount,
|
||||||
|
lexicalDiversity,
|
||||||
|
avgSentenceLength,
|
||||||
|
longestSentence,
|
||||||
|
longestSentenceLength,
|
||||||
|
topWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { analyze };
|
||||||
+284
-135
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ASCII Art Generator - MarkdownConverter</title>
|
<title>ASCII Art Generator - MarkdownConverter</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--ci-dark-gray: #464646;
|
--ci-dark-gray: #464646;
|
||||||
@@ -103,7 +106,9 @@
|
|||||||
color: var(--ci-dark-gray);
|
color: var(--ci-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input, .form-select, .form-textarea {
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 2px solid var(--ci-light-gray);
|
border: 2px solid var(--ci-light-gray);
|
||||||
@@ -113,7 +118,9 @@
|
|||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--ci-accent);
|
border-color: var(--ci-accent);
|
||||||
}
|
}
|
||||||
@@ -245,7 +252,13 @@
|
|||||||
<div id="text-mode" class="mode-section active">
|
<div id="text-mode" class="mode-section active">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Text to Convert</label>
|
<label class="form-label">Text to Convert</label>
|
||||||
<input type="text" id="text-input" class="form-input" placeholder="Enter your text..." maxlength="30">
|
<input
|
||||||
|
type="text"
|
||||||
|
id="text-input"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Enter your text..."
|
||||||
|
maxlength="30"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Style</label>
|
<label class="form-label">Style</label>
|
||||||
@@ -263,7 +276,11 @@
|
|||||||
<div id="box-mode" class="mode-section">
|
<div id="box-mode" class="mode-section">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Text Content</label>
|
<label class="form-label">Text Content</label>
|
||||||
<textarea id="box-text" class="form-textarea" placeholder="Enter text for the box..."></textarea>
|
<textarea
|
||||||
|
id="box-text"
|
||||||
|
class="form-textarea"
|
||||||
|
placeholder="Enter text for the box..."
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Box Style</label>
|
<label class="form-label">Box Style</label>
|
||||||
@@ -277,7 +294,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Padding</label>
|
<label class="form-label">Padding</label>
|
||||||
<input type="number" id="box-padding" class="form-input" min="0" max="10" value="2" style="width: 100px;">
|
<input
|
||||||
|
type="number"
|
||||||
|
id="box-padding"
|
||||||
|
class="form-input"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
value="2"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -341,138 +366,262 @@
|
|||||||
standard: {
|
standard: {
|
||||||
height: 5,
|
height: 5,
|
||||||
chars: {
|
chars: {
|
||||||
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
A: [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
||||||
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
B: ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
||||||
'C': ['/----\\', '| ', '| ', '| ', '\\----/'],
|
C: ['/----\\', '| ', '| ', '| ', '\\----/'],
|
||||||
'D': ['|----\\', '| |', '| |', '| |', '|----/'],
|
D: ['|----\\', '| |', '| |', '| |', '|----/'],
|
||||||
'E': ['|----', '| ', '|--- ', '| ', '|----'],
|
E: ['|----', '| ', '|--- ', '| ', '|----'],
|
||||||
'F': ['|----', '| ', '|--- ', '| ', '| '],
|
F: ['|----', '| ', '|--- ', '| ', '| '],
|
||||||
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
G: ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
||||||
'H': ['| |', '| |', '|----/', '| |', '| |'],
|
H: ['| |', '| |', '|----/', '| |', '| |'],
|
||||||
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
I: ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
||||||
'J': [' |', ' |', ' |', '| |', '\\---/'],
|
J: [' |', ' |', ' |', '| |', '\\---/'],
|
||||||
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
K: ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
||||||
'L': ['| ', '| ', '| ', '| ', '|----'],
|
L: ['| ', '| ', '| ', '| ', '|----'],
|
||||||
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
M: ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
||||||
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
N: ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
||||||
'O': ['/----\\', '| |', '| |', '| |', '\\----/'],
|
O: ['/----\\', '| |', '| |', '| |', '\\----/'],
|
||||||
'P': ['|----\\', '| |', '|----/', '| ', '| '],
|
P: ['|----\\', '| |', '|----/', '| ', '| '],
|
||||||
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
Q: ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
||||||
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
R: ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
||||||
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
S: ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
||||||
'T': ['-----', ' | ', ' | ', ' | ', ' | '],
|
T: ['-----', ' | ', ' | ', ' | ', ' | '],
|
||||||
'U': ['| |', '| |', '| |', '| |', '\\----/'],
|
U: ['| |', '| |', '| |', '| |', '\\----/'],
|
||||||
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
V: ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
||||||
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
W: ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
||||||
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
X: ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
||||||
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
Y: ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
||||||
'Z': ['-----', ' / ', ' / ', ' / ', '-----'],
|
Z: ['-----', ' / ', ' / ', ' / ', '-----'],
|
||||||
' ': [' ', ' ', ' ', ' ', ' '],
|
' ': [' ', ' ', ' ', ' ', ' '],
|
||||||
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
0: ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
||||||
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
1: [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
||||||
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
2: ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
||||||
'3': ['----\\', ' |', ' ---/', ' |', '----/'],
|
3: ['----\\', ' |', ' ---/', ' |', '----/'],
|
||||||
'4': ['| |', '| |', '-----', ' |', ' |'],
|
4: ['| |', '| |', '-----', ' |', ' |'],
|
||||||
'5': ['-----', '| ', '----\\', ' |', '----/'],
|
5: ['-----', '| ', '----\\', ' |', '----/'],
|
||||||
'6': ['/----', '| ', '|---\\', '| |', '\\---/'],
|
6: ['/----', '| ', '|---\\', '| |', '\\---/'],
|
||||||
'7': ['-----', ' / ', ' / ', ' / ', '/ '],
|
7: ['-----', ' / ', ' / ', ' / ', '/ '],
|
||||||
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
8: ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
||||||
'9': ['/---\\', '| |', '\\----', ' |', '----/']
|
9: ['/---\\', '| |', '\\----', ' |', '----/'],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
banner: {
|
banner: {
|
||||||
height: 7,
|
height: 7,
|
||||||
chars: {
|
chars: {
|
||||||
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
A: [
|
||||||
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '],
|
' ##### ',
|
||||||
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
' ## ##',
|
||||||
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '],
|
'## ##',
|
||||||
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
'#########',
|
||||||
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
'## ##',
|
||||||
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
'## ##',
|
||||||
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
|
'## ##',
|
||||||
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
],
|
||||||
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
B: [
|
||||||
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
'######## ',
|
||||||
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
'## ##',
|
||||||
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'],
|
'## ##',
|
||||||
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
'######## ',
|
||||||
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
'## ##',
|
||||||
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '],
|
'## ##',
|
||||||
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'],
|
'######## ',
|
||||||
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'],
|
],
|
||||||
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
C: [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
||||||
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
D: [
|
||||||
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
|
'######## ',
|
||||||
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '],
|
'## ##',
|
||||||
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '],
|
'## ##',
|
||||||
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'],
|
'## ##',
|
||||||
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
'## ##',
|
||||||
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
],
|
||||||
|
E: ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
||||||
|
F: ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
||||||
|
G: [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
||||||
|
H: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'#########',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
I: ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
||||||
|
J: [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
||||||
|
K: ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
||||||
|
L: ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
||||||
|
M: [
|
||||||
|
'## ##',
|
||||||
|
'### ###',
|
||||||
|
'#### ####',
|
||||||
|
'## ### ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
N: ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
||||||
|
O: [
|
||||||
|
' ####### ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ####### ',
|
||||||
|
],
|
||||||
|
P: [
|
||||||
|
'######## ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
'## ',
|
||||||
|
'## ',
|
||||||
|
'## ',
|
||||||
|
],
|
||||||
|
Q: [
|
||||||
|
' ####### ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ',
|
||||||
|
' ##### ##',
|
||||||
|
],
|
||||||
|
R: [
|
||||||
|
'######## ',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'######## ',
|
||||||
|
'## ## ',
|
||||||
|
'## ## ',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
S: [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
||||||
|
T: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
|
U: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ####### ',
|
||||||
|
],
|
||||||
|
V: [
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
'## ##',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
' ### ',
|
||||||
|
],
|
||||||
|
W: [
|
||||||
|
'## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
'## ## ##',
|
||||||
|
' ### ### ',
|
||||||
|
],
|
||||||
|
X: [
|
||||||
|
'## ##',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
' ### ',
|
||||||
|
' ## ## ',
|
||||||
|
' ## ## ',
|
||||||
|
'## ##',
|
||||||
|
],
|
||||||
|
Y: ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
|
Z: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
||||||
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
0: [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
||||||
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
1: [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
||||||
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
2: [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||||
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
3: [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
||||||
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
4: [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
||||||
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
5: ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
||||||
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
6: [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
||||||
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
7: ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||||
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
8: [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
||||||
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### ']
|
9: [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
block: {
|
block: {
|
||||||
height: 6,
|
height: 6,
|
||||||
chars: {
|
chars: {
|
||||||
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
A: ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
||||||
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
C: ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
||||||
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
||||||
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
||||||
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
G: ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
||||||
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
||||||
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
||||||
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
||||||
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
|
M: [
|
||||||
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
'███╗ ███╗',
|
||||||
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
'████╗ ████║',
|
||||||
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
'██╔████╔██║',
|
||||||
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
'██║╚██╔╝██║',
|
||||||
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
'██║ ╚═╝ ██║',
|
||||||
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
'╚═╝ ╚═╝',
|
||||||
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
],
|
||||||
'U': ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
||||||
'V': ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
O: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||||
'W': ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
P: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
||||||
'X': ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
Q: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
||||||
'Y': ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
||||||
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
S: ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
||||||
' ': [' ', ' ', ' ', ' ', ' ', ' ']
|
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
||||||
}
|
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||||
}
|
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
||||||
|
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
||||||
|
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
||||||
|
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
||||||
|
Z: ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
||||||
|
' ': [' ', ' ', ' ', ' ', ' ', ' '],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEMPLATES = {
|
const TEMPLATES = {
|
||||||
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
'arrow-right':
|
||||||
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
||||||
'decision': ' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
'arrow-down':
|
||||||
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
||||||
'flowchart': '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
decision:
|
||||||
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
||||||
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
process:
|
||||||
'hierarchy': ' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
'┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
||||||
'header': '╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
flowchart:
|
||||||
'note': '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
'┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
||||||
'warning': '╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
sequence:
|
||||||
'info': '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
||||||
'divider': '════════════════════════════════════════',
|
network:
|
||||||
'separator': '╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
||||||
'banner': '★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
hierarchy:
|
||||||
'checklist': '☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed'
|
' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
||||||
|
header:
|
||||||
|
'╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
||||||
|
note: '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
||||||
|
warning:
|
||||||
|
'╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
||||||
|
info: '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
||||||
|
divider: '════════════════════════════════════════',
|
||||||
|
separator:
|
||||||
|
'╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
||||||
|
banner:
|
||||||
|
'★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
||||||
|
checklist:
|
||||||
|
'☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOX_STYLES = {
|
const BOX_STYLES = {
|
||||||
@@ -480,20 +629,20 @@
|
|||||||
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
||||||
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
||||||
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
||||||
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
|
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentMode = 'text';
|
let currentMode = 'text';
|
||||||
let currentTemplate = null;
|
let currentTemplate = null;
|
||||||
|
|
||||||
// Mode switching
|
// Mode switching
|
||||||
document.querySelectorAll('.mode-tab').forEach(tab => {
|
document.querySelectorAll('.mode-tab').forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
currentMode = tab.dataset.mode;
|
currentMode = tab.dataset.mode;
|
||||||
|
|
||||||
document.querySelectorAll('.mode-section').forEach(s => s.classList.remove('active'));
|
document.querySelectorAll('.mode-section').forEach((s) => s.classList.remove('active'));
|
||||||
document.getElementById(currentMode + '-mode').classList.add('active');
|
document.getElementById(currentMode + '-mode').classList.add('active');
|
||||||
|
|
||||||
generatePreview();
|
generatePreview();
|
||||||
@@ -501,9 +650,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Template selection
|
// Template selection
|
||||||
document.querySelectorAll('.template-btn').forEach(btn => {
|
document.querySelectorAll('.template-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.template-btn').forEach((b) => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
currentTemplate = btn.dataset.template;
|
currentTemplate = btn.dataset.template;
|
||||||
generatePreview();
|
generatePreview();
|
||||||
@@ -531,7 +680,7 @@
|
|||||||
function generateBox(text, style, padding) {
|
function generateBox(text, style, padding) {
|
||||||
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const maxLen = Math.max(...lines.map(l => l.length)) + padding * 2;
|
const maxLen = Math.max(...lines.map((l) => l.length)) + padding * 2;
|
||||||
|
|
||||||
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,19 @@ class CommandPalette {
|
|||||||
|
|
||||||
renderResults(query) {
|
renderResults(query) {
|
||||||
this.filteredCommands = query
|
this.filteredCommands = query
|
||||||
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
: [...this.commands];
|
: [...this.commands];
|
||||||
|
|
||||||
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
|
this.results.innerHTML = this.filteredCommands
|
||||||
|
.map(
|
||||||
|
(cmd, i) => `
|
||||||
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
|
||||||
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
|
||||||
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
this.results.querySelectorAll('.command-item').forEach((el) => {
|
this.results.querySelectorAll('.command-item').forEach((el) => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
|
|||||||
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
|
||||||
// Language extensions loaded lazily on first use
|
// Language extensions loaded lazily on first use
|
||||||
let _javascript, _html, _css, _json, _python;
|
let _javascript, _html, _css, _json, _python;
|
||||||
const {
|
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
|
||||||
defaultKeymap,
|
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
|
||||||
history,
|
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
|
||||||
historyKeymap,
|
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
|
||||||
indentWithTab,
|
|
||||||
} = require('@codemirror/commands');
|
|
||||||
const {
|
|
||||||
searchKeymap,
|
|
||||||
highlightSelectionMatches,
|
|
||||||
} = require('@codemirror/search');
|
|
||||||
const {
|
|
||||||
autocompletion,
|
|
||||||
completionKeymap,
|
|
||||||
} = require('@codemirror/autocomplete');
|
|
||||||
const {
|
|
||||||
bracketMatching,
|
|
||||||
foldGutter,
|
|
||||||
indentOnInput,
|
|
||||||
} = require('@codemirror/language');
|
|
||||||
const { oneDark } = require('@codemirror/theme-one-dark');
|
const { oneDark } = require('@codemirror/theme-one-dark');
|
||||||
|
|
||||||
// Custom theme for JetBrains Mono font
|
// Custom theme for JetBrains Mono font
|
||||||
const jetBrainsMonoTheme = EditorView.theme({
|
const jetBrainsMonoTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
|
||||||
},
|
},
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit',
|
||||||
},
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +44,18 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
|
console.log(
|
||||||
|
'[createEditor] Called with parentElement:',
|
||||||
|
parentElement?.id,
|
||||||
|
'dimensions:',
|
||||||
|
parentElement?.clientWidth,
|
||||||
|
'x',
|
||||||
|
parentElement?.clientHeight
|
||||||
|
);
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
content = '',
|
content = '',
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
@@ -120,11 +117,26 @@ function createEditor(parentElement, options = {}) {
|
|||||||
*/
|
*/
|
||||||
function getLanguageExtension(lang) {
|
function getLanguageExtension(lang) {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
|
javascript: () => {
|
||||||
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
|
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
|
||||||
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
|
return _javascript();
|
||||||
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
|
},
|
||||||
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
|
html: () => {
|
||||||
|
if (!_html) _html = require('@codemirror/lang-html').html;
|
||||||
|
return _html();
|
||||||
|
},
|
||||||
|
css: () => {
|
||||||
|
if (!_css) _css = require('@codemirror/lang-css').css;
|
||||||
|
return _css();
|
||||||
|
},
|
||||||
|
json: () => {
|
||||||
|
if (!_json) _json = require('@codemirror/lang-json').json;
|
||||||
|
return _json();
|
||||||
|
},
|
||||||
|
python: () => {
|
||||||
|
if (!_python) _python = require('@codemirror/lang-python').python;
|
||||||
|
return _python();
|
||||||
|
},
|
||||||
markdown: () => markdown({ base: markdownLanguage }),
|
markdown: () => markdown({ base: markdownLanguage }),
|
||||||
};
|
};
|
||||||
loaders.js = loaders.javascript;
|
loaders.js = loaders.javascript;
|
||||||
|
|||||||
+1091
-233
File diff suppressed because it is too large
Load Diff
+1621
-1324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
|||||||
|
const simpleGit = require('simple-git');
|
||||||
|
|
||||||
|
function getGitInstance(dir) {
|
||||||
|
return simpleGit(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatus(dir) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
return await git.status();
|
||||||
|
} catch {
|
||||||
|
return { error: 'Not a git repository' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stage(dir, files) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
await git.add(files);
|
||||||
|
return await git.status();
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(dir, message) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
return await git.commit(message);
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function log(dir, maxCount = 20) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
return await git.log({ maxCount });
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, stage, commit, log };
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||||
|
|
||||||
|
function parsePageRanges(rangeString, totalPages) {
|
||||||
|
const pages = [];
|
||||||
|
const ranges = rangeString.split(',').map((r) => r.trim());
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||||
|
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||||
|
if (i > 0 && !pages.includes(i - 1)) {
|
||||||
|
pages.push(i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page = parseInt(range);
|
||||||
|
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||||
|
pages.push(page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result
|
||||||
|
? {
|
||||||
|
r: parseInt(result[1], 16) / 255,
|
||||||
|
g: parseInt(result[2], 16) / 255,
|
||||||
|
b: parseInt(result[3], 16) / 255,
|
||||||
|
}
|
||||||
|
: { r: 0, g: 0, b: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfMerge(data) {
|
||||||
|
try {
|
||||||
|
const mergedPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (const filePath of data.inputFiles) {
|
||||||
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||||
|
copiedPages.forEach((page) => mergedPdf.addPage(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await mergedPdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfSplit(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const splits = [];
|
||||||
|
|
||||||
|
if (data.splitMode === 'pages') {
|
||||||
|
const ranges = data.pageRanges.split(',').map((r) => r.trim());
|
||||||
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
|
const range = ranges[i];
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
|
||||||
|
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||||
|
pages.push(p - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page = parseInt(range);
|
||||||
|
if (page > 0 && page <= totalPages) {
|
||||||
|
pages.push(page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length > 0) {
|
||||||
|
splits.push({ pages, name: `part_${i + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'interval') {
|
||||||
|
const interval = data.interval;
|
||||||
|
for (let i = 0; i < totalPages; i += interval) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'size') {
|
||||||
|
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
|
||||||
|
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = path.basename(data.inputPath, '.pdf');
|
||||||
|
for (const split of splits) {
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||||
|
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||||
|
const newPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(outputPath, newPdfBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfCompress(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const compressedPdfBytes = await pdf.save({
|
||||||
|
useObjectStreams: true,
|
||||||
|
addDefaultPage: false,
|
||||||
|
objectsPerTick: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||||
|
|
||||||
|
const originalSize = fs.statSync(data.inputPath).size;
|
||||||
|
const compressedSize = fs.statSync(data.outputPath).size;
|
||||||
|
const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfRotate(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToRotate = [];
|
||||||
|
if (data.pages && data.pages.trim()) {
|
||||||
|
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||||
|
} else {
|
||||||
|
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesToRotate.forEach((pageIndex) => {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
page.setRotation(degrees(data.angle));
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotatedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDeletePages(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||||
|
|
||||||
|
pagesToDelete
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.forEach((pageIndex) => {
|
||||||
|
pdf.removePage(pageIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfReorder(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
|
||||||
|
|
||||||
|
if (newOrder.length !== totalPages) {
|
||||||
|
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||||
|
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const reorderedPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfWatermark(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToWatermark = [];
|
||||||
|
if (data.pages === 'all') {
|
||||||
|
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
} else if (data.pages === 'custom' && data.customPages) {
|
||||||
|
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
const color = hexToRgb(data.color);
|
||||||
|
|
||||||
|
for (const pageIndex of pagesToWatermark) {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
|
let x,
|
||||||
|
y,
|
||||||
|
rotation = 0;
|
||||||
|
|
||||||
|
switch (data.position) {
|
||||||
|
case 'center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
break;
|
||||||
|
case 'diagonal':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
rotation = 45;
|
||||||
|
break;
|
||||||
|
case 'top-left':
|
||||||
|
x = 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-left':
|
||||||
|
x = 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawText(data.text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size: data.fontSize,
|
||||||
|
font,
|
||||||
|
color: rgb(color.r, color.g, color.b),
|
||||||
|
opacity: data.opacity,
|
||||||
|
rotate: degrees(rotation),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const watermarkedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfEncrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const encryptedPdfBytes = await pdf.save({
|
||||||
|
userPassword: data.userPassword,
|
||||||
|
ownerPassword: data.ownerPassword || data.userPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDecrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||||
|
|
||||||
|
const decryptedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||||
|
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfSetPermissions(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save({
|
||||||
|
ownerPassword: data.ownerPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeOperation(operation, data) {
|
||||||
|
switch (operation) {
|
||||||
|
case 'merge':
|
||||||
|
return pdfMerge(data);
|
||||||
|
case 'split':
|
||||||
|
return pdfSplit(data);
|
||||||
|
case 'compress':
|
||||||
|
return pdfCompress(data);
|
||||||
|
case 'rotate':
|
||||||
|
return pdfRotate(data);
|
||||||
|
case 'delete':
|
||||||
|
return pdfDeletePages(data);
|
||||||
|
case 'reorder':
|
||||||
|
return pdfReorder(data);
|
||||||
|
case 'watermark':
|
||||||
|
return pdfWatermark(data);
|
||||||
|
case 'encrypt':
|
||||||
|
return pdfEncrypt(data);
|
||||||
|
case 'decrypt':
|
||||||
|
return pdfDecrypt(data);
|
||||||
|
case 'permissions':
|
||||||
|
return pdfSetPermissions(data);
|
||||||
|
default:
|
||||||
|
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageCount(filePath) {
|
||||||
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
return pdf.getPageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePageRanges,
|
||||||
|
hexToRgb,
|
||||||
|
pdfMerge,
|
||||||
|
pdfSplit,
|
||||||
|
pdfCompress,
|
||||||
|
pdfRotate,
|
||||||
|
pdfDeletePages,
|
||||||
|
pdfReorder,
|
||||||
|
pdfWatermark,
|
||||||
|
pdfEncrypt,
|
||||||
|
pdfDecrypt,
|
||||||
|
pdfSetPermissions,
|
||||||
|
executeOperation,
|
||||||
|
getPageCount,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const { PluginAPI } = require('../../plugin-api');
|
||||||
|
|
||||||
|
class SamplePlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
context.commands.register('hello', 'Sample: Hello World', () => {
|
||||||
|
console.log('[SamplePlugin] Hello from the plugin system!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Plugin: SamplePlugin };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"id": "_sample",
|
||||||
|
"name": "Sample Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Demonstrates the plugin system. Safe to delete.",
|
||||||
|
"icon": "puzzle",
|
||||||
|
"extensionPoints": {
|
||||||
|
"commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
|
||||||
|
},
|
||||||
|
"settings": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const HISTORY_KEY = 'plugins.writing-studio.history';
|
||||||
|
|
||||||
|
class GoalTracker {
|
||||||
|
/**
|
||||||
|
* @param {object} store - { get(key), set(key, value) } settings backend
|
||||||
|
*/
|
||||||
|
constructor(store) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getHistory() {
|
||||||
|
const raw = this.store.get(HISTORY_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
_setHistory(history) {
|
||||||
|
this.store.set(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
}
|
||||||
|
|
||||||
|
_setHistoryDay(dateStr, data) {
|
||||||
|
const history = this._getHistory();
|
||||||
|
history[dateStr] = data;
|
||||||
|
this._setHistory(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
addWords(count) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const history = this._getHistory();
|
||||||
|
if (!history[today]) {
|
||||||
|
history[today] = { words: 0, sessions: 0 };
|
||||||
|
}
|
||||||
|
history[today].words += count;
|
||||||
|
history[today].sessions += 1;
|
||||||
|
this._setHistory(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDailyProgress(goal) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const history = this._getHistory();
|
||||||
|
const written = history[today]?.words || 0;
|
||||||
|
return { written, goal, pct: goal > 0 ? Math.min(100, Math.round((written / goal) * 100)) : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
getStreak(goal) {
|
||||||
|
const history = this._getHistory();
|
||||||
|
let streak = 0;
|
||||||
|
const d = new Date();
|
||||||
|
for (let i = 0; i < 365; i++) {
|
||||||
|
const key = d.toISOString().split('T')[0];
|
||||||
|
const day = history[key];
|
||||||
|
if (day && day.words >= goal) {
|
||||||
|
streak++;
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLast30Days() {
|
||||||
|
const history = this._getHistory();
|
||||||
|
const days = [];
|
||||||
|
const d = new Date();
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const key = d.toISOString().split('T')[0];
|
||||||
|
const day = history[key];
|
||||||
|
days.push({ date: key, words: day?.words || 0 });
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
}
|
||||||
|
return days.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
getWeeklyTotal() {
|
||||||
|
const history = this._getHistory();
|
||||||
|
let total = 0;
|
||||||
|
const d = new Date();
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const key = d.toISOString().split('T')[0];
|
||||||
|
if (history[key]) total += history[key].words || 0;
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { GoalTracker };
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
const { PluginAPI } = require('../../../plugins/plugin-api');
|
||||||
|
const { SprintEngine } = require('./sprint-engine');
|
||||||
|
const { GoalTracker } = require('./goal-tracker');
|
||||||
|
const { SnapshotManager } = require('./snapshot-manager');
|
||||||
|
const { ProjectManager } = require('./project-manager');
|
||||||
|
|
||||||
|
class WritingStudioPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
this.sprintEngine = new SprintEngine({
|
||||||
|
onEvent: (name, data) => context.events.emit(name, data),
|
||||||
|
});
|
||||||
|
this.goalTracker = new GoalTracker(context.settings);
|
||||||
|
this.snapshotManager = new SnapshotManager(context.settings);
|
||||||
|
this.projectManager = new ProjectManager({
|
||||||
|
readFile: (p) => context.ipc.invoke('read-file', p),
|
||||||
|
writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
|
||||||
|
fileExists: (p) => context.ipc.invoke('path-exists', p),
|
||||||
|
listDir: (p) => context.ipc.invoke('list-directory', p),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._engines = {
|
||||||
|
sprint: this.sprintEngine,
|
||||||
|
goals: this.goalTracker,
|
||||||
|
snapshots: this.snapshotManager,
|
||||||
|
projects: this.projectManager,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._registerCommands(context);
|
||||||
|
this._registerStatusBar(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerCommands(context) {
|
||||||
|
const { sprintEngine, snapshotManager, goalTracker } = this;
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'start-sprint',
|
||||||
|
'Studio: Start Sprint',
|
||||||
|
() => {
|
||||||
|
const duration = context.settings.get('sprintDuration') || 25;
|
||||||
|
const content = context.editor.getContent() || '';
|
||||||
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
|
sprintEngine.start(duration, words);
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+S'
|
||||||
|
);
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'stop-sprint',
|
||||||
|
'Studio: Stop Sprint',
|
||||||
|
() => {
|
||||||
|
if (!sprintEngine.isActive()) return;
|
||||||
|
const content = context.editor.getContent() || '';
|
||||||
|
const words = content.split(/\s+/).filter(Boolean).length;
|
||||||
|
const result = sprintEngine.stop(words);
|
||||||
|
goalTracker.addWords(result.wordDelta);
|
||||||
|
context.events.emit('sprint:stopped', result);
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+Shift+S'
|
||||||
|
);
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'take-snapshot',
|
||||||
|
'Studio: Take Snapshot',
|
||||||
|
() => {
|
||||||
|
const content = context.editor.getContent() || '';
|
||||||
|
snapshotManager.create(content, 'manual');
|
||||||
|
context.events.emit('snapshot:created', {});
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+N'
|
||||||
|
);
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'restore-last-snapshot',
|
||||||
|
'Studio: Restore Last Snapshot',
|
||||||
|
() => {
|
||||||
|
const snaps = snapshotManager.list();
|
||||||
|
if (snaps.length === 0) return;
|
||||||
|
const content = snapshotManager.restore(snaps[0].id);
|
||||||
|
context.editor.insertAtCursor(content);
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
context.commands.register('new-project', 'Studio: New Project', () => {
|
||||||
|
context.events.emit('studio:new-project', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'compile-manuscript',
|
||||||
|
'Studio: Compile Manuscript',
|
||||||
|
() => {
|
||||||
|
context.events.emit('studio:compile', {});
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+E'
|
||||||
|
);
|
||||||
|
|
||||||
|
context.commands.register(
|
||||||
|
'proofread-document',
|
||||||
|
'Studio: Proofread Document',
|
||||||
|
() => {
|
||||||
|
if (context.events.hasHandler('ai:analyze')) {
|
||||||
|
const content = context.editor.getContent() || '';
|
||||||
|
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'Ctrl+Alt+G'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerStatusBar(context) {
|
||||||
|
context.statusBar.registerIndicator('word-goal', {
|
||||||
|
text: '0/1000',
|
||||||
|
tooltip: 'Daily word goal progress',
|
||||||
|
});
|
||||||
|
context.statusBar.registerIndicator('sprint-timer', {
|
||||||
|
text: '',
|
||||||
|
tooltip: 'Writing sprint timer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
if (this._sprintInterval) clearInterval(this._sprintInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEngines() {
|
||||||
|
return this._engines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Plugin: WritingStudioPlugin };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"id": "writing-studio",
|
||||||
|
"name": "Writing Studio",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Manuscript management, writing sprints, goal tracking, snapshots, and smart proofreading",
|
||||||
|
"icon": "pen-tool",
|
||||||
|
"extensionPoints": {
|
||||||
|
"sidebar": [
|
||||||
|
{ "id": "manuscript", "title": "Manuscript", "order": 30 },
|
||||||
|
{ "id": "goals", "title": "Goals", "order": 31 },
|
||||||
|
{ "id": "snapshots", "title": "Snapshots", "order": 32 },
|
||||||
|
{ "id": "proofread", "title": "Proofread", "order": 33 }
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
|
||||||
|
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
|
||||||
|
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
|
||||||
|
{
|
||||||
|
"id": "restore-last-snapshot",
|
||||||
|
"label": "Studio: Restore Last Snapshot",
|
||||||
|
"shortcut": "Ctrl+Alt+Z"
|
||||||
|
},
|
||||||
|
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
|
||||||
|
{
|
||||||
|
"id": "compile-manuscript",
|
||||||
|
"label": "Studio: Compile Manuscript",
|
||||||
|
"shortcut": "Ctrl+Alt+E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "proofread-document",
|
||||||
|
"label": "Studio: Proofread Document",
|
||||||
|
"shortcut": "Ctrl+Alt+G"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"statusBar": { "indicators": ["sprint-timer", "word-goal"] }
|
||||||
|
},
|
||||||
|
"settings": [
|
||||||
|
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
|
||||||
|
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
|
||||||
|
{
|
||||||
|
"key": "autoSnapshotInterval",
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"label": "Auto-snapshot interval (min, 0=off)"
|
||||||
|
},
|
||||||
|
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
function renderGoalsPanel(container, { engines, settings }) {
|
||||||
|
const dailyGoal = settings.get('dailyGoal') || 1000;
|
||||||
|
const progress = engines.goals.getDailyProgress(dailyGoal);
|
||||||
|
const streak = engines.goals.getStreak(dailyGoal);
|
||||||
|
const weekly = engines.goals.getWeeklyTotal();
|
||||||
|
const last30 = engines.goals.getLast30Days();
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
// Daily progress section
|
||||||
|
const section1 = document.createElement('div');
|
||||||
|
section1.className = 'ws-section';
|
||||||
|
const heading1 = document.createElement('h3');
|
||||||
|
heading1.className = 'ws-heading';
|
||||||
|
heading1.textContent = 'Daily Progress';
|
||||||
|
section1.appendChild(heading1);
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'ws-progress-bar';
|
||||||
|
const fill = document.createElement('div');
|
||||||
|
fill.className = 'ws-progress-fill';
|
||||||
|
fill.style.width = progress.pct + '%';
|
||||||
|
bar.appendChild(fill);
|
||||||
|
section1.appendChild(bar);
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ws-stat-row';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent =
|
||||||
|
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
|
||||||
|
const pct = document.createElement('span');
|
||||||
|
pct.className = 'ws-pct';
|
||||||
|
pct.textContent = progress.pct + '%';
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(pct);
|
||||||
|
section1.appendChild(row);
|
||||||
|
panel.appendChild(section1);
|
||||||
|
|
||||||
|
// Stats cards
|
||||||
|
const section2 = document.createElement('div');
|
||||||
|
section2.className = 'ws-section';
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'ws-stat-grid';
|
||||||
|
|
||||||
|
const streakCard = document.createElement('div');
|
||||||
|
streakCard.className = 'ws-stat-card';
|
||||||
|
const streakVal = document.createElement('span');
|
||||||
|
streakVal.className = 'ws-stat-value';
|
||||||
|
streakVal.textContent = String(streak);
|
||||||
|
const streakLbl = document.createElement('span');
|
||||||
|
streakLbl.className = 'ws-stat-label';
|
||||||
|
streakLbl.textContent = 'Day Streak';
|
||||||
|
streakCard.appendChild(streakVal);
|
||||||
|
streakCard.appendChild(streakLbl);
|
||||||
|
|
||||||
|
const weekCard = document.createElement('div');
|
||||||
|
weekCard.className = 'ws-stat-card';
|
||||||
|
const weekVal = document.createElement('span');
|
||||||
|
weekVal.className = 'ws-stat-value';
|
||||||
|
weekVal.textContent = weekly.toLocaleString();
|
||||||
|
const weekLbl = document.createElement('span');
|
||||||
|
weekLbl.className = 'ws-stat-label';
|
||||||
|
weekLbl.textContent = 'This Week';
|
||||||
|
weekCard.appendChild(weekVal);
|
||||||
|
weekCard.appendChild(weekLbl);
|
||||||
|
|
||||||
|
grid.appendChild(streakCard);
|
||||||
|
grid.appendChild(weekCard);
|
||||||
|
section2.appendChild(grid);
|
||||||
|
panel.appendChild(section2);
|
||||||
|
|
||||||
|
// 30-day chart
|
||||||
|
const section3 = document.createElement('div');
|
||||||
|
section3.className = 'ws-section';
|
||||||
|
const heading3 = document.createElement('h3');
|
||||||
|
heading3.className = 'ws-heading';
|
||||||
|
heading3.textContent = 'Last 30 Days';
|
||||||
|
section3.appendChild(heading3);
|
||||||
|
|
||||||
|
const chart = document.createElement('div');
|
||||||
|
chart.className = 'ws-chart';
|
||||||
|
const maxWords = Math.max(...last30.map((d) => d.words), 1);
|
||||||
|
for (const day of last30) {
|
||||||
|
const barEl = document.createElement('div');
|
||||||
|
const height = Math.max(2, (day.words / maxWords) * 60);
|
||||||
|
barEl.className = 'ws-bar' + (day.words >= dailyGoal ? ' ws-bar-met' : '');
|
||||||
|
barEl.style.height = height + 'px';
|
||||||
|
barEl.title = day.date + ': ' + day.words + ' words';
|
||||||
|
chart.appendChild(barEl);
|
||||||
|
}
|
||||||
|
section3.appendChild(chart);
|
||||||
|
panel.appendChild(section3);
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderGoalsPanel };
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
function renderManuscriptPanel(container, { engines, editor, settings }) {
|
||||||
|
const projectDir = settings.get('projectDir');
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
if (!projectDir) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'ws-empty';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = 'No manuscript project open';
|
||||||
|
empty.appendChild(p);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ws-btn ws-btn-primary';
|
||||||
|
btn.id = 'ws-new-project';
|
||||||
|
btn.textContent = 'New Project';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const name = prompt('Project name:');
|
||||||
|
if (!name) return;
|
||||||
|
settings.set('projectDir', name);
|
||||||
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
|
empty.appendChild(btn);
|
||||||
|
panel.appendChild(empty);
|
||||||
|
container.appendChild(panel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = engines.projects.loadProject(projectDir);
|
||||||
|
if (!project) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'ws-empty';
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = 'Project not found at ' + projectDir;
|
||||||
|
empty.appendChild(p);
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ws-btn';
|
||||||
|
btn.id = 'ws-clear-project';
|
||||||
|
btn.textContent = 'Clear Project';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
settings.set('projectDir', null);
|
||||||
|
renderManuscriptPanel(container, { engines, editor, settings });
|
||||||
|
});
|
||||||
|
empty.appendChild(btn);
|
||||||
|
panel.appendChild(empty);
|
||||||
|
container.appendChild(panel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = engines.projects.getStats(projectDir);
|
||||||
|
|
||||||
|
// Project title + progress
|
||||||
|
const section1 = document.createElement('div');
|
||||||
|
section1.className = 'ws-section';
|
||||||
|
const heading = document.createElement('h3');
|
||||||
|
heading.className = 'ws-heading';
|
||||||
|
heading.textContent = project.title;
|
||||||
|
section1.appendChild(heading);
|
||||||
|
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'ws-progress-bar';
|
||||||
|
const fill = document.createElement('div');
|
||||||
|
fill.className = 'ws-progress-fill';
|
||||||
|
fill.style.width = stats.pctComplete + '%';
|
||||||
|
bar.appendChild(fill);
|
||||||
|
section1.appendChild(bar);
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'ws-stat-row';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent =
|
||||||
|
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
|
||||||
|
const pct = document.createElement('span');
|
||||||
|
pct.className = 'ws-pct';
|
||||||
|
pct.textContent = stats.pctComplete + '%';
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(pct);
|
||||||
|
section1.appendChild(row);
|
||||||
|
panel.appendChild(section1);
|
||||||
|
|
||||||
|
// Chapters list
|
||||||
|
const section2 = document.createElement('div');
|
||||||
|
section2.className = 'ws-section';
|
||||||
|
const heading2 = document.createElement('h3');
|
||||||
|
heading2.className = 'ws-heading';
|
||||||
|
heading2.textContent = 'Chapters (' + project.chapters.length + ')';
|
||||||
|
section2.appendChild(heading2);
|
||||||
|
|
||||||
|
const chList = document.createElement('div');
|
||||||
|
chList.className = 'ws-chapter-list';
|
||||||
|
for (const ch of project.chapters) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ws-chapter-item';
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'ws-chapter-title';
|
||||||
|
title.textContent = ch.title || ch.file;
|
||||||
|
const status = document.createElement('span');
|
||||||
|
status.className = 'ws-chapter-status ws-status-' + (ch.status || 'draft');
|
||||||
|
status.textContent = ch.status || 'draft';
|
||||||
|
item.appendChild(title);
|
||||||
|
item.appendChild(status);
|
||||||
|
chList.appendChild(item);
|
||||||
|
}
|
||||||
|
section2.appendChild(chList);
|
||||||
|
panel.appendChild(section2);
|
||||||
|
|
||||||
|
// Compile button
|
||||||
|
const section3 = document.createElement('div');
|
||||||
|
section3.className = 'ws-section';
|
||||||
|
const compileBtn = document.createElement('button');
|
||||||
|
compileBtn.className = 'ws-btn ws-btn-primary';
|
||||||
|
compileBtn.id = 'ws-compile';
|
||||||
|
compileBtn.textContent = 'Compile Manuscript';
|
||||||
|
compileBtn.addEventListener('click', () => {
|
||||||
|
const compiled = engines.projects.compileManuscript(projectDir);
|
||||||
|
editor.insertAtCursor(compiled);
|
||||||
|
});
|
||||||
|
section3.appendChild(compileBtn);
|
||||||
|
panel.appendChild(section3);
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderManuscriptPanel };
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
function renderProofreadPanel(container, { events, editor }) {
|
||||||
|
const hasAI = events.hasHandler('ai:analyze');
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'ws-section';
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ws-btn ws-btn-primary';
|
||||||
|
btn.id = 'ws-proofread';
|
||||||
|
btn.textContent = hasAI ? 'Check Document' : 'AI Plugin Required';
|
||||||
|
btn.disabled = !hasAI;
|
||||||
|
section.appendChild(btn);
|
||||||
|
|
||||||
|
if (!hasAI) {
|
||||||
|
const note = document.createElement('p');
|
||||||
|
note.className = 'ws-muted';
|
||||||
|
note.textContent = 'Install the AI Assistant plugin to enable proofreading.';
|
||||||
|
section.appendChild(note);
|
||||||
|
}
|
||||||
|
panel.appendChild(section);
|
||||||
|
|
||||||
|
const issuesList = document.createElement('div');
|
||||||
|
issuesList.className = 'ws-issues-list';
|
||||||
|
issuesList.id = 'ws-issues';
|
||||||
|
panel.appendChild(issuesList);
|
||||||
|
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
if (!hasAI) return;
|
||||||
|
|
||||||
|
container.querySelector('#ws-proofread').addEventListener('click', () => {
|
||||||
|
const content = editor.getContent() || '';
|
||||||
|
events.emit('ai:analyze', {
|
||||||
|
text: content,
|
||||||
|
type: 'grammar',
|
||||||
|
callback: (result) => {
|
||||||
|
if (result && result.issues) {
|
||||||
|
renderIssues(issuesList, result.issues);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIssues(container, issues) {
|
||||||
|
container.replaceChildren();
|
||||||
|
if (!issues || issues.length === 0) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'ws-muted';
|
||||||
|
p.textContent = 'No issues found.';
|
||||||
|
container.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
const issue = issues[i];
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ws-issue-item';
|
||||||
|
|
||||||
|
const type = document.createElement('div');
|
||||||
|
type.className = 'ws-issue-type';
|
||||||
|
type.textContent = (issue.type || 'grammar').toUpperCase();
|
||||||
|
item.appendChild(type);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'ws-issue-text';
|
||||||
|
text.textContent = issue.message || issue.text || '';
|
||||||
|
item.appendChild(text);
|
||||||
|
|
||||||
|
if (issue.suggestion) {
|
||||||
|
const sug = document.createElement('div');
|
||||||
|
sug.className = 'ws-issue-suggestion';
|
||||||
|
sug.textContent = 'Suggestion: ' + issue.suggestion;
|
||||||
|
item.appendChild(sug);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'ws-issue-actions';
|
||||||
|
for (const [, label] of [
|
||||||
|
['accept', 'Accept'],
|
||||||
|
['dismiss', 'Dismiss'],
|
||||||
|
]) {
|
||||||
|
const actionBtn = document.createElement('button');
|
||||||
|
actionBtn.className = 'ws-btn ws-btn-sm';
|
||||||
|
actionBtn.textContent = label;
|
||||||
|
actionBtn.addEventListener('click', () => {
|
||||||
|
item.remove();
|
||||||
|
});
|
||||||
|
actions.appendChild(actionBtn);
|
||||||
|
}
|
||||||
|
item.appendChild(actions);
|
||||||
|
container.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderProofreadPanel };
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
function renderSnapshotsPanel(container, { engines, editor }) {
|
||||||
|
const snapshots = engines.snapshots.list();
|
||||||
|
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.className = 'ws-panel';
|
||||||
|
|
||||||
|
// Header with take snapshot button
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'ws-section';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ws-btn ws-btn-primary';
|
||||||
|
btn.id = 'ws-take-snapshot';
|
||||||
|
btn.textContent = 'Take Snapshot';
|
||||||
|
section.appendChild(btn);
|
||||||
|
const count = document.createElement('span');
|
||||||
|
count.className = 'ws-muted';
|
||||||
|
count.textContent = snapshots.length + ' snapshots';
|
||||||
|
section.appendChild(count);
|
||||||
|
panel.appendChild(section);
|
||||||
|
|
||||||
|
// Snapshot list
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'ws-snapshot-list';
|
||||||
|
for (const s of snapshots) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'ws-snapshot-item';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'ws-snapshot-header';
|
||||||
|
const sLabel = document.createElement('span');
|
||||||
|
sLabel.className = 'ws-snapshot-label';
|
||||||
|
sLabel.textContent = s.label;
|
||||||
|
const sWords = document.createElement('span');
|
||||||
|
sWords.textContent = s.wordCount + ' words';
|
||||||
|
header.appendChild(sLabel);
|
||||||
|
header.appendChild(sWords);
|
||||||
|
item.appendChild(header);
|
||||||
|
|
||||||
|
const time = document.createElement('div');
|
||||||
|
time.className = 'ws-snapshot-time';
|
||||||
|
time.textContent = new Date(s.timestamp).toLocaleString();
|
||||||
|
item.appendChild(time);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'ws-snapshot-actions';
|
||||||
|
for (const [action, text, cls] of [
|
||||||
|
['restore', 'Restore', ''],
|
||||||
|
['diff', 'Diff', ''],
|
||||||
|
['delete', 'Delete', 'ws-btn-danger'],
|
||||||
|
]) {
|
||||||
|
const actionBtn = document.createElement('button');
|
||||||
|
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
|
||||||
|
actionBtn.textContent = text;
|
||||||
|
actionBtn.addEventListener('click', () => {
|
||||||
|
if (action === 'restore') {
|
||||||
|
const content = engines.snapshots.restore(s.id);
|
||||||
|
editor.insertAtCursor(content);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
engines.snapshots.delete(s.id);
|
||||||
|
renderSnapshotsPanel(container, { engines, editor });
|
||||||
|
} else if (action === 'diff') {
|
||||||
|
const current = editor.getContent() || '';
|
||||||
|
const result = engines.snapshots.diff(s.id, current);
|
||||||
|
alert('+' + result.added + ' lines added, -' + result.removed + ' lines removed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.appendChild(actionBtn);
|
||||||
|
}
|
||||||
|
item.appendChild(actions);
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
panel.appendChild(list);
|
||||||
|
container.appendChild(panel);
|
||||||
|
|
||||||
|
// Take snapshot button handler
|
||||||
|
container.querySelector('#ws-take-snapshot').addEventListener('click', () => {
|
||||||
|
const content = editor.getContent() || '';
|
||||||
|
engines.snapshots.create(content, 'manual');
|
||||||
|
renderSnapshotsPanel(container, { engines, editor });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderSnapshotsPanel };
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
class ProjectManager {
|
||||||
|
/**
|
||||||
|
* @param {object} fs - { readFile(path), writeFile(path, content), fileExists(path), listDir(path) }
|
||||||
|
*/
|
||||||
|
constructor(fs) {
|
||||||
|
this.fs = fs;
|
||||||
|
}
|
||||||
|
|
||||||
|
createProject(dir, opts) {
|
||||||
|
const project = {
|
||||||
|
title: opts.title,
|
||||||
|
type: opts.type || 'manuscript',
|
||||||
|
target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
|
||||||
|
chapters: [],
|
||||||
|
metadata: opts.metadata || {},
|
||||||
|
};
|
||||||
|
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProject(dir) {
|
||||||
|
const raw = this.fs.readFile(dir + '/.project.json');
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveProject(dir, project) {
|
||||||
|
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
addChapter(dir, chapter) {
|
||||||
|
const project = this.loadProject(dir);
|
||||||
|
if (!project) throw new Error('Project not found');
|
||||||
|
project.chapters.push(chapter);
|
||||||
|
this._saveProject(dir, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChapter(dir, index, updates) {
|
||||||
|
const project = this.loadProject(dir);
|
||||||
|
if (!project) throw new Error('Project not found');
|
||||||
|
Object.assign(project.chapters[index], updates);
|
||||||
|
this._saveProject(dir, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
compileManuscript(dir) {
|
||||||
|
const project = this.loadProject(dir);
|
||||||
|
if (!project) throw new Error('Project not found');
|
||||||
|
const parts = [];
|
||||||
|
for (const ch of project.chapters) {
|
||||||
|
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||||
|
if (content) parts.push(content);
|
||||||
|
}
|
||||||
|
return parts.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(dir) {
|
||||||
|
const project = this.loadProject(dir);
|
||||||
|
if (!project) throw new Error('Project not found');
|
||||||
|
let totalWords = 0;
|
||||||
|
for (const ch of project.chapters) {
|
||||||
|
const content = this.fs.readFile(dir + '/' + ch.file);
|
||||||
|
if (content) totalWords += content.split(/\s+/).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
const target = project.target.words || 0;
|
||||||
|
return {
|
||||||
|
totalWords,
|
||||||
|
chapterCount: project.chapters.length,
|
||||||
|
targetWords: target,
|
||||||
|
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ProjectManager };
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
class SnapshotManager {
|
||||||
|
/**
|
||||||
|
* @param {object} store - { get(key), set(key, value) }
|
||||||
|
* @param {string} storeKey - settings key for snapshots
|
||||||
|
*/
|
||||||
|
constructor(store, storeKey = 'plugins.writing-studio.snapshots') {
|
||||||
|
this.store = store;
|
||||||
|
this.storeKey = storeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getAll() {
|
||||||
|
const raw = this.store.get(this.storeKey);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveAll(snaps) {
|
||||||
|
this.store.set(this.storeKey, JSON.stringify(snaps));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(content, label = 'manual') {
|
||||||
|
const snaps = this._getAll();
|
||||||
|
const snap = {
|
||||||
|
id: 'snap-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
content,
|
||||||
|
wordCount: content.split(/\s+/).filter(Boolean).length,
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
snaps.unshift(snap);
|
||||||
|
this._saveAll(snaps);
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this._getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id) {
|
||||||
|
return this._getAll().find((s) => s.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(id) {
|
||||||
|
const snap = this.getById(id);
|
||||||
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
|
return snap.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
const snaps = this._getAll().filter((s) => s.id !== id);
|
||||||
|
this._saveAll(snaps);
|
||||||
|
}
|
||||||
|
|
||||||
|
diff(id, currentContent) {
|
||||||
|
const snap = this.getById(id);
|
||||||
|
if (!snap) throw new Error('Snapshot not found');
|
||||||
|
const oldLines = snap.content.split('\n');
|
||||||
|
const newLines = currentContent.split('\n');
|
||||||
|
const oldSet = new Set(oldLines);
|
||||||
|
const newSet = new Set(newLines);
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
for (const line of newLines) {
|
||||||
|
if (!oldSet.has(line)) added++;
|
||||||
|
}
|
||||||
|
for (const line of oldLines) {
|
||||||
|
if (!newSet.has(line)) removed++;
|
||||||
|
}
|
||||||
|
return { added, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(keepCount) {
|
||||||
|
const snaps = this._getAll();
|
||||||
|
this._saveAll(snaps.slice(0, keepCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SnapshotManager };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
class SprintEngine {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {function} opts.onEvent - callback(event_name, data)
|
||||||
|
*/
|
||||||
|
constructor(opts = {}) {
|
||||||
|
this.onEvent = opts.onEvent || (() => {});
|
||||||
|
this._active = false;
|
||||||
|
this._startTime = null;
|
||||||
|
this._duration = 0;
|
||||||
|
this._initialWords = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(durationMinutes, currentWordCount) {
|
||||||
|
if (this._active) throw new Error('Sprint already active');
|
||||||
|
this._active = true;
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._duration = durationMinutes * 60 * 1000;
|
||||||
|
this._initialWords = currentWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(currentWordCount) {
|
||||||
|
if (!this._active) throw new Error('No active sprint');
|
||||||
|
const elapsed = Date.now() - this._startTime;
|
||||||
|
const wordDelta = Math.max(0, currentWordCount - this._initialWords);
|
||||||
|
const elapsedMinutes = elapsed / 60000;
|
||||||
|
const wpm = elapsedMinutes > 0 ? Math.round(wordDelta / elapsedMinutes) : 0;
|
||||||
|
this._active = false;
|
||||||
|
this._startTime = null;
|
||||||
|
return { wordDelta, elapsed, wpm };
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(elapsedMs) {
|
||||||
|
if (!this._active) return;
|
||||||
|
const remaining = Math.max(0, this._duration - elapsedMs);
|
||||||
|
this.onEvent('sprint:tick', { remaining, elapsed: elapsedMs });
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this._active = false;
|
||||||
|
this.onEvent('sprint:complete', { expired: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive() {
|
||||||
|
return this._active;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemaining() {
|
||||||
|
if (!this._active) return 0;
|
||||||
|
return Math.max(0, this._duration - (Date.now() - this._startTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SprintEngine };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class EventBus {
|
||||||
|
constructor() {
|
||||||
|
this.listeners = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event).push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, handler) {
|
||||||
|
if (!handler) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (handlers) {
|
||||||
|
const idx = handlers.indexOf(handler);
|
||||||
|
if (idx !== -1) handlers.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, payload) {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (!handlers) return;
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
handler(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[EventBus] Error in handler for "${event}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasHandler(event) {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
return handlers !== undefined && handlers !== null && handlers.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { EventBus };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class PluginAPI {
|
||||||
|
/**
|
||||||
|
* Called when the plugin is discovered and loaded.
|
||||||
|
* Receives a scoped context object with APIs.
|
||||||
|
* @param {object} context - Plugin context (sidebar, commands, settings, etc.)
|
||||||
|
*/
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the plugin is activated (e.g., sidebar panel clicked). */
|
||||||
|
activate() {}
|
||||||
|
|
||||||
|
/** Called when the plugin is deactivated. */
|
||||||
|
deactivate() {}
|
||||||
|
|
||||||
|
/** Returns the parsed manifest.json for this plugin. */
|
||||||
|
getManifest() {
|
||||||
|
return this._manifest || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginAPI };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
class PluginContext {
|
||||||
|
/**
|
||||||
|
* @param {object} deps - Injected dependencies
|
||||||
|
* @param {string} deps.pluginId - Plugin unique ID (for namespacing)
|
||||||
|
* @param {object} deps.sidebar - SidebarManager.registerPanel
|
||||||
|
* @param {object} deps.commands - CommandPalette.register
|
||||||
|
* @param {object} deps.statusBar - StatusBar.registerIndicator
|
||||||
|
* @param {object} deps.eventBus - EventBus instance
|
||||||
|
* @param {object} deps.settings - { get, set, onChanged }
|
||||||
|
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
|
||||||
|
* @param {object} deps.ipc - { invoke, on }
|
||||||
|
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
|
||||||
|
deps;
|
||||||
|
|
||||||
|
this.sidebar = {
|
||||||
|
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.commands = {
|
||||||
|
register: (id, label, handler, shortcut) => {
|
||||||
|
const safeHandler = (...args) => {
|
||||||
|
try {
|
||||||
|
handler(...args);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.statusBar = {
|
||||||
|
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.settings = {
|
||||||
|
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||||
|
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||||
|
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.editor = {
|
||||||
|
getContent: () => editor.getContent(),
|
||||||
|
getSelection: () => editor.getSelection(),
|
||||||
|
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||||
|
onContentChanged: (cb) => editor.onContentChanged(cb),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.events = {
|
||||||
|
on: (event, handler) => eventBus.on(event, handler),
|
||||||
|
off: (event, handler) => eventBus.off(event, handler),
|
||||||
|
emit: (event, payload) => eventBus.emit(event, payload),
|
||||||
|
hasHandler: (event) => eventBus.hasHandler(event),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ipc = {
|
||||||
|
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||||
|
on: (channel, handler) => ipc.on(channel, handler),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.exports = {
|
||||||
|
registerPreHook: (handler) => {
|
||||||
|
if (exportHooks) exportHooks.preHooks.push(handler);
|
||||||
|
},
|
||||||
|
registerPostHook: (handler) => {
|
||||||
|
if (exportHooks) exportHooks.postHooks.push(handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginContext };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class PluginLoader {
|
||||||
|
/**
|
||||||
|
* @param {string[]} searchDirs - Directories to scan for plugin folders
|
||||||
|
*/
|
||||||
|
constructor(searchDirs = []) {
|
||||||
|
this.searchDirs = searchDirs;
|
||||||
|
this.loadedIds = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover plugins by scanning searchDirs for manifest.json files.
|
||||||
|
* Returns array of { id, name, version, description, manifest, PluginClass, dir }
|
||||||
|
*/
|
||||||
|
discoverPlugins() {
|
||||||
|
const plugins = [];
|
||||||
|
for (const dir of this.searchDirs) {
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const pluginDir = path.join(dir, entry.name);
|
||||||
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||||
|
const manifest = JSON.parse(raw);
|
||||||
|
this.validateManifest(manifest);
|
||||||
|
let PluginClass = null;
|
||||||
|
const indexPath = path.join(pluginDir, 'index.js');
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
try {
|
||||||
|
const loaded = require(indexPath);
|
||||||
|
PluginClass = loaded.Plugin || loaded.default || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins.push({
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
version: manifest.version,
|
||||||
|
description: manifest.description,
|
||||||
|
manifest,
|
||||||
|
PluginClass,
|
||||||
|
dir: pluginDir,
|
||||||
|
});
|
||||||
|
this.loadedIds.add(manifest.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a manifest object. Throws on invalid.
|
||||||
|
*/
|
||||||
|
validateManifest(manifest) {
|
||||||
|
if (!manifest.id) throw new Error('Manifest missing required field: id');
|
||||||
|
if (!manifest.name) throw new Error('Manifest missing required field: name');
|
||||||
|
if (!manifest.version) throw new Error('Manifest missing required field: version');
|
||||||
|
if (!manifest.description) throw new Error('Manifest missing required field: description');
|
||||||
|
if (this.loadedIds.has(manifest.id)) {
|
||||||
|
throw new Error(`Duplicate plugin id: "${manifest.id}"`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginLoader };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { PluginContext } = require('./plugin-context');
|
||||||
|
const { PluginAPI } = require('./plugin-api');
|
||||||
|
|
||||||
|
class PluginRegistry {
|
||||||
|
constructor(deps) {
|
||||||
|
this.deps = deps;
|
||||||
|
this.plugins = new Map();
|
||||||
|
this.exportHooks = { preHooks: [], postHooks: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a discovered plugin. Creates instance, builds context, calls init.
|
||||||
|
* If init throws, plugin is NOT registered.
|
||||||
|
*/
|
||||||
|
register(pluginInfo) {
|
||||||
|
const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo;
|
||||||
|
|
||||||
|
let instance;
|
||||||
|
if (PluginClass) {
|
||||||
|
instance = new PluginClass();
|
||||||
|
} else {
|
||||||
|
instance = new PluginAPI();
|
||||||
|
}
|
||||||
|
instance._manifest = manifest;
|
||||||
|
|
||||||
|
const context = new PluginContext({
|
||||||
|
pluginId: id,
|
||||||
|
sidebar: this.deps.sidebar,
|
||||||
|
commands: this.deps.commands,
|
||||||
|
statusBar: this.deps.statusBar,
|
||||||
|
eventBus: this.deps.eventBus,
|
||||||
|
settings: this.deps.settings,
|
||||||
|
editor: this.deps.editor,
|
||||||
|
ipc: this.deps.ipc,
|
||||||
|
exportHooks: this.exportHooks,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
instance.init(context);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context });
|
||||||
|
console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlugin(id) {
|
||||||
|
return this.plugins.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
return Array.from(this.plugins.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(id) {
|
||||||
|
const plugin = this.plugins.get(id);
|
||||||
|
if (plugin?.instance) {
|
||||||
|
try {
|
||||||
|
plugin.instance.activate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(id) {
|
||||||
|
const plugin = this.plugins.get(id);
|
||||||
|
if (plugin?.instance) {
|
||||||
|
try {
|
||||||
|
plugin.instance.deactivate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginRegistry };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class SettingsStore {
|
||||||
|
/**
|
||||||
|
* @param {object} backend - { get(key), set(key, value) } backed by main process store
|
||||||
|
*/
|
||||||
|
constructor(backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
return this.backend.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
this.backend.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanged(_key, _callback) {
|
||||||
|
// Deferred: plugins read settings on init/activate for MVP.
|
||||||
|
// Full change notification requires IPC watcher in main process.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SettingsStore };
|
||||||
+51
-19
@@ -9,7 +9,7 @@
|
|||||||
* - All IPC channels are explicitly whitelisted
|
* - All IPC channels are explicitly whitelisted
|
||||||
* - Prevents XSS from escalating to full system access
|
* - Prevents XSS from escalating to full system access
|
||||||
*
|
*
|
||||||
* @version 4.0.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'save-recent-files',
|
'save-recent-files',
|
||||||
'clear-recent-files',
|
'clear-recent-files',
|
||||||
'renderer-ready',
|
'renderer-ready',
|
||||||
|
'select-custom-css',
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
'get-theme',
|
'get-theme',
|
||||||
@@ -101,6 +102,14 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
|
|
||||||
// File Explorer
|
// File Explorer
|
||||||
'list-directory',
|
'list-directory',
|
||||||
|
'read-file',
|
||||||
|
'write-file',
|
||||||
|
'delete-file',
|
||||||
|
'ensure-directory',
|
||||||
|
'path-exists',
|
||||||
|
'is-directory',
|
||||||
|
'copy-path',
|
||||||
|
'move-path',
|
||||||
|
|
||||||
// Git
|
// Git
|
||||||
'git-status',
|
'git-status',
|
||||||
@@ -127,7 +136,11 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'export',
|
'export',
|
||||||
|
|
||||||
// Git diff
|
// Git diff
|
||||||
'git-diff'
|
'git-diff',
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
'plugin-settings:get',
|
||||||
|
'plugin-settings:set',
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -138,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'get-content-for-save',
|
'get-content-for-save',
|
||||||
'get-content-for-spreadsheet',
|
'get-content-for-spreadsheet',
|
||||||
'recent-files-cleared',
|
'recent-files-cleared',
|
||||||
|
'load-custom-css',
|
||||||
|
'clear-custom-css',
|
||||||
|
|
||||||
// UI toggles
|
// UI toggles
|
||||||
'toggle-preview',
|
'toggle-preview',
|
||||||
@@ -222,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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,23 +336,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
|
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
|
||||||
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
|
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
|
||||||
clearRecent: () => ipcRenderer.send('clear-recent-files'),
|
clearRecent: () => ipcRenderer.send('clear-recent-files'),
|
||||||
rendererReady: () => ipcRenderer.send('renderer-ready')
|
rendererReady: () => ipcRenderer.send('renderer-ready'),
|
||||||
|
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
|
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
|
||||||
|
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
||||||
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
|
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||||
|
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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
|
||||||
@@ -345,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
|
||||||
@@ -354,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
|
||||||
},
|
},
|
||||||
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
|
||||||
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
|
ipcRenderer.send('universal-convert-batch', {
|
||||||
}
|
tool,
|
||||||
|
fromFormat,
|
||||||
|
toFormat,
|
||||||
|
inputFolder,
|
||||||
|
outputFolder,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Header/Footer Operations
|
// Header/Footer Operations
|
||||||
@@ -363,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
|
||||||
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
|
||||||
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
|
||||||
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
saveLogo: (position, filePath) =>
|
||||||
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
|
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
|
||||||
|
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Page Settings
|
// Page Settings
|
||||||
page: {
|
page: {
|
||||||
getSettings: () => ipcRenderer.send('get-page-settings'),
|
getSettings: () => ipcRenderer.send('get-page-settings'),
|
||||||
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
|
||||||
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
|
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
|
||||||
},
|
},
|
||||||
|
|
||||||
// PDF Operations
|
// PDF Operations
|
||||||
pdf: {
|
pdf: {
|
||||||
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
|
||||||
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
|
||||||
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
|
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image Converter Operations
|
// Image Converter Operations
|
||||||
@@ -387,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
|
||||||
resize: (data) => ipcRenderer.send('image-resize', data),
|
resize: (data) => ipcRenderer.send('image-resize', data),
|
||||||
compress: (data) => ipcRenderer.send('image-compress', data),
|
compress: (data) => ipcRenderer.send('image-compress', data),
|
||||||
rotate: (data) => ipcRenderer.send('image-rotate', data)
|
rotate: (data) => ipcRenderer.send('image-rotate', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Audio Converter Operations
|
// Audio Converter Operations
|
||||||
@@ -396,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
|
||||||
extract: (data) => ipcRenderer.send('audio-extract', data),
|
extract: (data) => ipcRenderer.send('audio-extract', data),
|
||||||
trim: (data) => ipcRenderer.send('audio-trim', data),
|
trim: (data) => ipcRenderer.send('audio-trim', data),
|
||||||
merge: (data) => ipcRenderer.send('audio-merge', data)
|
merge: (data) => ipcRenderer.send('audio-merge', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Video Converter Operations
|
// Video Converter Operations
|
||||||
@@ -406,14 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
compress: (data) => ipcRenderer.send('video-compress', data),
|
compress: (data) => ipcRenderer.send('video-compress', data),
|
||||||
trim: (data) => ipcRenderer.send('video-trim', data),
|
trim: (data) => ipcRenderer.send('video-trim', data),
|
||||||
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
extractFrames: (data) => ipcRenderer.send('video-frames', data),
|
||||||
toGif: (data) => ipcRenderer.send('video-gif', data)
|
toGif: (data) => ipcRenderer.send('video-gif', data),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Generator Windows
|
// Generator Windows
|
||||||
generators: {
|
generators: {
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||||
openTable: () => ipcRenderer.send('open-table-generator')
|
openTable: () => ipcRenderer.send('open-table-generator'),
|
||||||
}
|
},
|
||||||
|
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful preload initialization
|
// Log successful preload initialization
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class PrintPreview {
|
|||||||
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
|
||||||
|
|
||||||
// Update preview on option changes
|
// Update preview on option changes
|
||||||
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
|
['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
|
||||||
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,12 +67,12 @@ class PrintPreview {
|
|||||||
|
|
||||||
// Get dimensions for paper size
|
// Get dimensions for paper size
|
||||||
const sizes = {
|
const sizes = {
|
||||||
'A3': { width: '297mm', height: '420mm' },
|
A3: { width: '297mm', height: '420mm' },
|
||||||
'A4': { width: '210mm', height: '297mm' },
|
A4: { width: '210mm', height: '297mm' },
|
||||||
'A5': { width: '148mm', height: '210mm' },
|
A5: { width: '148mm', height: '210mm' },
|
||||||
'Letter': { width: '8.5in', height: '11in' },
|
Letter: { width: '8.5in', height: '11in' },
|
||||||
'Legal': { width: '8.5in', height: '14in' },
|
Legal: { width: '8.5in', height: '14in' },
|
||||||
'Tabloid': { width: '11in', height: '17in' },
|
Tabloid: { width: '11in', height: '17in' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const size = sizes[paperSize] || sizes['A4'];
|
const size = sizes[paperSize] || sizes['A4'];
|
||||||
|
|||||||
+2290
-812
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
|||||||
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">
|
||||||
@@ -15,19 +13,33 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
|
|||||||
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(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, dir.path);
|
renderTree(
|
||||||
|
document.getElementById('explorer-tree'),
|
||||||
|
dir.entries,
|
||||||
|
listDirectory,
|
||||||
|
onFileOpen,
|
||||||
|
dir.path
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentDir) {
|
if (currentDir) {
|
||||||
listDirectory(currentDir).then(dir => {
|
listDirectory(currentDir).then((dir) => {
|
||||||
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir);
|
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.map(entry => {
|
container.innerHTML = entries
|
||||||
|
.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>
|
||||||
@@ -39,9 +51,10 @@ function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
|
|||||||
<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) {
|
||||||
@@ -52,18 +65,29 @@ function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.classList.toggle('collapsed');
|
el.classList.toggle('collapsed');
|
||||||
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed') ? '\u25B6' : '\u25BC';
|
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed')
|
||||||
|
? '\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 = { md: '\u{1F4DD}', js: '\u{1F4DC}', json: '{}', html: '\u{1F310}', css: '\u{1F3A8}', py: '\u{1F40D}', pdf: '\u{1F4D5}', txt: '\u{1F4C4}' };
|
const icons = {
|
||||||
|
md: '\u{1F4DD}',
|
||||||
|
js: '\u{1F4DC}',
|
||||||
|
json: '{}',
|
||||||
|
html: '\u{1F310}',
|
||||||
|
css: '\u{1F3A8}',
|
||||||
|
py: '\u{1F40D}',
|
||||||
|
pdf: '\u{1F4D5}',
|
||||||
|
txt: '\u{1F4C4}',
|
||||||
|
};
|
||||||
return icons[ext] || '\u{1F4C4}';
|
return icons[ext] || '\u{1F4C4}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-11
@@ -1,4 +1,4 @@
|
|||||||
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">
|
||||||
@@ -32,25 +32,29 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.map(f => `
|
changesEl.innerHTML = files
|
||||||
|
.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]);
|
||||||
@@ -63,12 +67,18 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
|
|||||||
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 = (log.all || []).slice(0, 10).map(entry => `
|
logEl.innerHTML =
|
||||||
|
(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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Document Outline Panel
|
||||||
|
* Parses markdown headings and renders a navigable tree in the sidebar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="outline-panel">
|
||||||
|
<div class="outline-list" id="outline-list"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const listEl = document.getElementById('outline-list');
|
||||||
|
let headings = [];
|
||||||
|
let activeLine = 1;
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
function parseHeadings(content) {
|
||||||
|
const result = [];
|
||||||
|
if (!content) return result;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const regex = /^(#{1,6})\s+(.+)$/;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const match = regex.exec(lines[i]);
|
||||||
|
if (match) {
|
||||||
|
result.push({
|
||||||
|
level: match[1].length,
|
||||||
|
text: match[2].trim(),
|
||||||
|
line: i + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveHeading(currentLine) {
|
||||||
|
let active = null;
|
||||||
|
for (const h of headings) {
|
||||||
|
if (h.line <= currentLine) {
|
||||||
|
active = h;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeadings() {
|
||||||
|
const content = getEditorContent();
|
||||||
|
headings = parseHeadings(content);
|
||||||
|
activeLine = getActiveLine();
|
||||||
|
|
||||||
|
if (headings.length === 0) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="outline-empty">
|
||||||
|
<p>No headings found</p>
|
||||||
|
<p class="outline-hint"># Heading 1</p>
|
||||||
|
<p class="outline-hint">## Heading 2</p>
|
||||||
|
<p class="outline-hint">### Heading 3</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeHeading = findActiveHeading(activeLine);
|
||||||
|
|
||||||
|
listEl.innerHTML =
|
||||||
|
headings
|
||||||
|
.map(
|
||||||
|
(h, idx) => `
|
||||||
|
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
|
||||||
|
data-line="${h.line}" data-index="${idx}">
|
||||||
|
<span class="outline-text">${escapeHtml(h.text)}</span>
|
||||||
|
<span class="outline-badge">H${h.level}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('') +
|
||||||
|
`<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.outline-item').forEach((item) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const line = parseInt(item.dataset.line, 10);
|
||||||
|
onHeadingClick(line);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(renderHeadings, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveHeading(line) {
|
||||||
|
activeLine = line;
|
||||||
|
const activeHeading = findActiveHeading(line);
|
||||||
|
|
||||||
|
listEl.querySelectorAll('.outline-item').forEach((item) => {
|
||||||
|
const itemLine = parseInt(item.dataset.line, 10);
|
||||||
|
if (activeHeading && itemLine === activeHeading.line) {
|
||||||
|
item.classList.add('active');
|
||||||
|
} else {
|
||||||
|
item.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container._refreshOutline = refresh;
|
||||||
|
container._setActiveHeading = setActiveHeading;
|
||||||
|
|
||||||
|
renderHeadings();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderOutlinePanel };
|
||||||
@@ -9,10 +9,12 @@ class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
document.querySelectorAll('.sidebar-icon').forEach(btn => {
|
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
|
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
|
||||||
});
|
});
|
||||||
document.querySelector('.sidebar-panel-close')?.addEventListener('click', () => this.collapse());
|
document
|
||||||
|
.querySelector('.sidebar-panel-close')
|
||||||
|
?.addEventListener('click', () => this.collapse());
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPanel(name, { title, render }) {
|
registerPanel(name, { title, render }) {
|
||||||
@@ -35,7 +37,7 @@ class SidebarManager {
|
|||||||
this.panelContent.innerHTML = '';
|
this.panelContent.innerHTML = '';
|
||||||
panel.render(this.panelContent);
|
panel.render(this.panelContent);
|
||||||
this.activePanel = name;
|
this.activePanel = name;
|
||||||
document.querySelectorAll('.sidebar-icon').forEach(btn => {
|
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
|
||||||
btn.classList.toggle('active', btn.dataset.panel === name);
|
btn.classList.toggle('active', btn.dataset.panel === name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,7 +45,7 @@ class SidebarManager {
|
|||||||
collapse() {
|
collapse() {
|
||||||
this.sidebar.classList.add('collapsed');
|
this.sidebar.classList.add('collapsed');
|
||||||
this.activePanel = null;
|
this.activePanel = null;
|
||||||
document.querySelectorAll('.sidebar-icon').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.sidebar-icon').forEach((btn) => btn.classList.remove('active'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
|
|||||||
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 || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +21,17 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
|
|||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? snippets.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.language || '').toLowerCase().includes(query.toLowerCase()))
|
? snippets.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
(s.language || '').toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
: snippets;
|
: snippets;
|
||||||
|
|
||||||
list.innerHTML = filtered.length ? filtered.map(s => `
|
list.innerHTML = filtered.length
|
||||||
|
? 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>
|
||||||
@@ -36,16 +43,19 @@ 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 => {
|
list.querySelectorAll('.snippet-delete').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
await deleteSnippet(btn.dataset.id);
|
await deleteSnippet(btn.dataset.id);
|
||||||
loadSnippets();
|
loadSnippets();
|
||||||
@@ -53,7 +63,9 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('snippets-search')?.addEventListener('input', (e) => renderList(e.target.value));
|
document
|
||||||
|
.getElementById('snippets-search')
|
||||||
|
?.addEventListener('input', (e) => renderList(e.target.value));
|
||||||
|
|
||||||
document.getElementById('snippets-add')?.addEventListener('click', () => {
|
document.getElementById('snippets-add')?.addEventListener('click', () => {
|
||||||
const name = prompt('Snippet name:');
|
const name = prompt('Snippet name:');
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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',
|
||||||
|
file: 'technical-spec.md',
|
||||||
|
description: 'Requirements and architecture',
|
||||||
|
},
|
||||||
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
|
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
|
||||||
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
|
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
|
||||||
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
|
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
|
||||||
@@ -17,16 +18,20 @@ const templates = [
|
|||||||
function renderTemplatesPanel(container, onSelect) {
|
function renderTemplatesPanel(container, onSelect) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="panel-list">
|
<div class="panel-list">
|
||||||
${templates.map(t => `
|
${templates
|
||||||
|
.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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ConcreteInfo Theme for MarkdownConverter
|
* ConcreteInfo Theme for MarkdownConverter
|
||||||
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
|
||||||
* Version: 3.0.0
|
* Version: 4.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -675,18 +675,33 @@ body.theme-concreteinfo-dark .modal-footer {
|
|||||||
Animations
|
Animations
|
||||||
============================================ */
|
============================================ */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
from {
|
||||||
to { transform: translateY(0); opacity: 1; }
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { transform: scale(1); }
|
0%,
|
||||||
50% { transform: scale(1.05); }
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
|
|||||||
+446
-35
@@ -60,7 +60,15 @@
|
|||||||
|
|
||||||
/* Base styles - Reset is in styles.css */
|
/* Base styles - Reset is in styles.css */
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family:
|
||||||
|
'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 */
|
||||||
@@ -68,9 +76,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes gradientShift {
|
@keyframes gradientShift {
|
||||||
0% { background-position: 0% 50%; }
|
0% {
|
||||||
50% { background-position: 100% 50%; }
|
background-position: 0% 50%;
|
||||||
100% { background-position: 0% 50%; }
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -345,7 +359,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Preview Styles - Modern Typography */
|
/* Preview Styles - Modern Typography */
|
||||||
.preview-content h1, .preview-content h2, .preview-content h3 {
|
.preview-content h1,
|
||||||
|
.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;
|
||||||
@@ -695,11 +711,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(90deg,
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.3),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
animation: shimmer 2s infinite;
|
animation: shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +810,9 @@ 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: width 0.6s, height 0.6s;
|
transition:
|
||||||
|
width 0.6s,
|
||||||
|
height 0.6s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar button:hover::before,
|
.toolbar button:hover::before,
|
||||||
@@ -1444,7 +1458,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;
|
||||||
@@ -1452,9 +1466,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;
|
||||||
@@ -1491,9 +1505,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;
|
||||||
}
|
}
|
||||||
@@ -1539,27 +1553,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;
|
||||||
}
|
}
|
||||||
@@ -2207,20 +2221,20 @@ body.theme-rosepine-dawn .batch-dialog-footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
@@ -2240,7 +2254,7 @@ body[class*="dark"] .command-shortcut {
|
|||||||
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;
|
||||||
@@ -2292,7 +2306,7 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
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;
|
||||||
@@ -2304,7 +2318,7 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
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 {
|
||||||
@@ -2346,3 +2360,400 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Writing Analytics Modal */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.analytics-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
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
|
||||||
|
======================================== */
|
||||||
|
.pdf-editor-body.side-by-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
height: 550px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-left-panel {
|
||||||
|
flex: 1.2;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel {
|
||||||
|
flex: 0.8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
padding-left: 16px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel h4 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-editor-right-panel .sidebar-help {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 8px;
|
||||||
|
transition: all var(--transition-speed, 0.3s) var(--transition-ease);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--primary-light, #8b9aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete {
|
||||||
|
border-color: var(--error, #ef4444);
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-card.marked-delete::after {
|
||||||
|
content: 'DELETE';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-15deg);
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-header .badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
height: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Rotations */
|
||||||
|
.rot90 {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.rot180 {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.rot270 {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-controls button,
|
||||||
|
.pdf-thumbnail-controls label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail {
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-rotate-thumbnail:hover {
|
||||||
|
background: var(--primary-light, #8b9aff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-thumbnail-checkbox input {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-thumbnail-reorder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left,
|
||||||
|
.btn-reorder-right {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:hover:not(:disabled),
|
||||||
|
.btn-reorder-right:hover:not(:disabled) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reorder-left:disabled,
|
||||||
|
.btn-reorder-right:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-thumbnails,
|
||||||
|
.thumbnail-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-error {
|
||||||
|
color: var(--error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
body[class*='dark'] .pdf-thumbnail-card {
|
||||||
|
background: var(--gray-800, #1f2937);
|
||||||
|
border-color: var(--gray-700, #374151);
|
||||||
|
}
|
||||||
|
body[class*='dark'] .pdf-thumbnail-header .badge {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
body[class*='dark'] .canvas-container {
|
||||||
|
background: var(--gray-900, #111827);
|
||||||
|
border-color: var(--gray-700);
|
||||||
|
}
|
||||||
|
body[class*='dark'] .btn-rotate-thumbnail,
|
||||||
|
body[class*='dark'] .btn-reorder-left,
|
||||||
|
body[class*='dark'] .btn-reorder-right {
|
||||||
|
background: var(--gray-700, #374151);
|
||||||
|
border-color: var(--gray-600);
|
||||||
|
color: var(--gray-200);
|
||||||
|
}
|
||||||
|
|||||||
+292
-47
@@ -115,28 +115,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
@@ -162,13 +162,13 @@ body[class*="dark"] .sidebar-panel-header {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,59 +220,304 @@ body[class*="dark"] .panel-list-item-desc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Git Panel */
|
/* Git Panel */
|
||||||
.git-section { margin-bottom: 16px; }
|
.git-section {
|
||||||
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; }
|
margin-bottom: 16px;
|
||||||
.git-file { display: flex; align-items: center; padding: 4px 6px; border-radius: 4px; font-size: 13px; gap: 6px; }
|
}
|
||||||
.git-file:hover { background: var(--gray-100, #f3f4f6); }
|
.git-section-title {
|
||||||
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; }
|
font-size: 11px;
|
||||||
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
text-transform: uppercase;
|
||||||
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; }
|
letter-spacing: 0.05em;
|
||||||
.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; }
|
color: var(--gray-500);
|
||||||
.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; }
|
margin-bottom: 8px;
|
||||||
.git-commit-btn:hover { opacity: 0.9; }
|
}
|
||||||
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); }
|
.git-file {
|
||||||
.git-log-msg { font-size: 13px; }
|
display: flex;
|
||||||
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; }
|
align-items: center;
|
||||||
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; }
|
padding: 4px 6px;
|
||||||
.git-loading { font-size: 13px; color: var(--gray-400); }
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.git-file:hover {
|
||||||
|
background: var(--gray-100, #f3f4f6);
|
||||||
|
}
|
||||||
|
.git-file-status {
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.git-file-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.git-stage-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.git-commit-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.git-commit-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--primary-dark, #5661b3);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.git-commit-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.git-log-entry {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
.git-log-msg {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.git-log-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.git-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.git-loading {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
/* Snippets Panel */
|
/* Snippets Panel */
|
||||||
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
.snippets-toolbar {
|
||||||
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
|
display: flex;
|
||||||
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; }
|
gap: 4px;
|
||||||
.snippet-item { padding: 8px; border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 6px; }
|
margin-bottom: 8px;
|
||||||
.snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
}
|
||||||
.snippet-name { font-size: 13px; font-weight: 500; }
|
.snippets-search {
|
||||||
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); }
|
flex: 1;
|
||||||
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; }
|
padding: 6px 10px;
|
||||||
.snippet-preview code { font-family: 'JetBrains Mono', monospace; }
|
border: 1px solid var(--gray-300);
|
||||||
.snippet-actions { display: flex; gap: 4px; }
|
border-radius: 6px;
|
||||||
.snippet-insert { font-size: 12px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; }
|
font-size: 13px;
|
||||||
.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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.outline-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.outline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.outline-item:hover {
|
||||||
|
background: var(--bg-tertiary, #f3f4f6);
|
||||||
|
color: var(--text-primary, #1f2937);
|
||||||
|
}
|
||||||
|
.outline-item.active {
|
||||||
|
color: var(--accent-blue, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
border-left-color: var(--accent-blue, #3b82f6);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.outline-level-1 {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.outline-level-2 {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
.outline-level-3 {
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
.outline-level-4 {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
.outline-level-5 {
|
||||||
|
padding-left: 56px;
|
||||||
|
}
|
||||||
|
.outline-level-6 {
|
||||||
|
padding-left: 64px;
|
||||||
|
}
|
||||||
|
.outline-text {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.outline-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.outline-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: var(--text-muted, #9ca3af);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.outline-empty p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.outline-hint {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.outline-footer {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline dark mode */
|
||||||
|
body[class*='dark'] .outline-item {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
body[class*='dark'] .outline-item:hover {
|
||||||
|
background: #374151;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
body[class*='dark'] .outline-item.active {
|
||||||
|
color: #8b9aff;
|
||||||
|
background: rgba(139, 154, 255, 0.1);
|
||||||
|
border-left-color: #8b9aff;
|
||||||
|
}
|
||||||
|
body[class*='dark'] .outline-empty {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
body[class*='dark'] .outline-footer {
|
||||||
|
border-color: #333;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|||||||
+121
-24
@@ -1,24 +1,121 @@
|
|||||||
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; }
|
.welcome-container {
|
||||||
.welcome-hero { text-align: center; margin-bottom: 40px; }
|
padding: 40px;
|
||||||
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
|
max-width: 900px;
|
||||||
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; }
|
margin: 0 auto;
|
||||||
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); margin-top: 8px; }
|
}
|
||||||
.welcome-grid { display: grid; gap: 32px; }
|
.welcome-hero {
|
||||||
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); }
|
text-align: center;
|
||||||
.welcome-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
margin-bottom: 40px;
|
||||||
.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-title {
|
||||||
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; }
|
font-size: 32px;
|
||||||
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; }
|
font-weight: 700;
|
||||||
.welcome-card p { font-size: 13px; color: var(--gray-500); }
|
color: var(--gray-800, #1f2937);
|
||||||
.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-version {
|
||||||
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); }
|
font-size: 14px;
|
||||||
.welcome-features strong { color: var(--primary-dark, #5661b3); }
|
color: var(--primary-dark, #5661b3);
|
||||||
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
|
margin-top: 4px;
|
||||||
.welcome-recent-item:hover { background: var(--gray-100); }
|
font-weight: 500;
|
||||||
.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-subtitle {
|
||||||
.welcome-muted { color: var(--gray-400); font-size: 14px; }
|
font-size: 16px;
|
||||||
.welcome-footer { margin-top: 32px; text-align: center; }
|
color: var(--gray-500, #6b7280);
|
||||||
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; }
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.welcome-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
.welcome-section h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--gray-700, #374151);
|
||||||
|
}
|
||||||
|
.welcome-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.welcome-card {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--gray-200, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.welcome-card:hover {
|
||||||
|
border-color: var(--primary-dark, #5661b3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.welcome-card-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.welcome-card h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.welcome-card p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
.welcome-card kbd {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--gray-100);
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
.welcome-features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.welcome-features li {
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid var(--gray-100, #f3f4f6);
|
||||||
|
}
|
||||||
|
.welcome-features strong {
|
||||||
|
color: var(--primary-dark, #5661b3);
|
||||||
|
}
|
||||||
|
.welcome-recent-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.welcome-recent-item:hover {
|
||||||
|
background: var(--gray-100);
|
||||||
|
}
|
||||||
|
.welcome-recent-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.welcome-recent-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.welcome-muted {
|
||||||
|
color: var(--gray-400);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.welcome-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.welcome-checkbox {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/* Zen Mode — Distraction-free writing styles */
|
||||||
|
|
||||||
|
/* Hide chrome elements */
|
||||||
|
body.zen-mode .app-header,
|
||||||
|
body.zen-mode .tab-bar,
|
||||||
|
body.zen-mode .toolbar,
|
||||||
|
body.zen-mode .status-bar,
|
||||||
|
body.zen-mode .sidebar,
|
||||||
|
body.zen-mode .breadcrumb-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content fills viewport */
|
||||||
|
body.zen-mode .main-content {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor container takes full width */
|
||||||
|
body.zen-mode .editor-container {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide preview panes */
|
||||||
|
body.zen-mode .preview-container,
|
||||||
|
body.zen-mode .preview-pane,
|
||||||
|
body.zen-mode .pane-resizer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center and style editor content for comfortable reading width */
|
||||||
|
body.zen-mode .cm-content {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode .cm-line {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.zen-mode .editor-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating HUD pill */
|
||||||
|
.zen-hud {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-stat {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-hud-sep {
|
||||||
|
opacity: 0.3;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Word goal progress bar */
|
||||||
|
.zen-progress-container {
|
||||||
|
width: 120px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zen-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 178, 70, 0.85);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode — HUD stays dark in both themes */
|
||||||
|
body[class*='dark'].zen-mode .zen-hud {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
+437
-204
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Modal System Styles
|
* Modal System Styles
|
||||||
* Unified modal components with glassmorphism backdrop
|
* Unified modal components with glassmorphism backdrop
|
||||||
* @version 4.0.0
|
* @version 4.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
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: opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)),
|
transition:
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,8 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color var(--transition-fast, 150ms),
|
transition:
|
||||||
|
background-color var(--transition-fast, 150ms),
|
||||||
color var(--transition-fast, 150ms);
|
color var(--transition-fast, 150ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,15 +235,17 @@
|
|||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.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: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3);
|
box-shadow:
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-17
@@ -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,24 +205,56 @@
|
|||||||
* Semantic Color Classes
|
* Semantic Color Classes
|
||||||
* ============================================ */
|
* ============================================ */
|
||||||
|
|
||||||
.bg-background { background-color: hsl(var(--background)); }
|
.bg-background {
|
||||||
.bg-foreground { background-color: hsl(var(--foreground)); }
|
background-color: hsl(var(--background));
|
||||||
.bg-card { background-color: hsl(var(--card)); }
|
}
|
||||||
.bg-primary { background-color: hsl(var(--primary)); }
|
.bg-foreground {
|
||||||
.bg-secondary { background-color: hsl(var(--secondary)); }
|
background-color: hsl(var(--foreground));
|
||||||
.bg-muted { background-color: hsl(var(--muted)); }
|
}
|
||||||
.bg-accent { background-color: hsl(var(--accent)); }
|
.bg-card {
|
||||||
.bg-destructive { background-color: hsl(var(--destructive)); }
|
background-color: hsl(var(--card));
|
||||||
|
}
|
||||||
|
.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 { color: hsl(var(--foreground)); }
|
.text-foreground {
|
||||||
.text-primary { color: hsl(var(--primary)); }
|
color: hsl(var(--foreground));
|
||||||
.text-secondary { color: hsl(var(--secondary-foreground)); }
|
}
|
||||||
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
|
.text-primary {
|
||||||
.text-destructive { color: hsl(var(--destructive)); }
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.text-secondary {
|
||||||
|
color: hsl(var(--secondary-foreground));
|
||||||
|
}
|
||||||
|
.text-muted-foreground {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.text-destructive {
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
|
||||||
.border-border { border-color: hsl(var(--border)); }
|
.border-border {
|
||||||
.border-primary { border-color: hsl(var(--primary)); }
|
border-color: hsl(var(--border));
|
||||||
.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
|
||||||
|
|||||||
+37
-16
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Table Generator - MarkdownConverter</title>
|
<title>Table Generator - MarkdownConverter</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--ci-dark-gray: #464646;
|
--ci-dark-gray: #464646;
|
||||||
@@ -86,7 +89,8 @@
|
|||||||
color: var(--ci-medium-gray);
|
color: var(--ci-medium-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input, .form-select {
|
.form-input,
|
||||||
|
.form-select {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 2px solid var(--ci-light-gray);
|
border: 2px solid var(--ci-light-gray);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -96,7 +100,8 @@
|
|||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus, .form-select:focus {
|
.form-input:focus,
|
||||||
|
.form-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--ci-accent);
|
border-color: var(--ci-accent);
|
||||||
}
|
}
|
||||||
@@ -274,11 +279,27 @@
|
|||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<span class="control-label">Rows</span>
|
<span class="control-label">Rows</span>
|
||||||
<input type="number" id="rows" class="form-input" min="1" max="50" value="4" style="width: 80px;">
|
<input
|
||||||
|
type="number"
|
||||||
|
id="rows"
|
||||||
|
class="form-input"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value="4"
|
||||||
|
style="width: 80px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<span class="control-label">Columns</span>
|
<span class="control-label">Columns</span>
|
||||||
<input type="number" id="cols" class="form-input" min="1" max="20" value="4" style="width: 80px;">
|
<input
|
||||||
|
type="number"
|
||||||
|
id="cols"
|
||||||
|
class="form-input"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value="4"
|
||||||
|
style="width: 80px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<span class="control-label">Alignment</span>
|
<span class="control-label">Alignment</span>
|
||||||
@@ -288,9 +309,9 @@
|
|||||||
<option value="right">Right</option>
|
<option value="right">Right</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" style="justify-content: flex-end;">
|
<div class="control-group" style="justify-content: flex-end">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="has-header" checked>
|
<input type="checkbox" id="has-header" checked />
|
||||||
Include Header Row
|
Include Header Row
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,7 +381,7 @@
|
|||||||
table.innerHTML = html;
|
table.innerHTML = html;
|
||||||
|
|
||||||
// Add event listeners for live preview
|
// Add event listeners for live preview
|
||||||
table.querySelectorAll('input').forEach(input => {
|
table.querySelectorAll('input').forEach((input) => {
|
||||||
input.addEventListener('input', updatePreview);
|
input.addEventListener('input', updatePreview);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,7 +446,7 @@
|
|||||||
for (let i = startRow; i < data.length; i++) {
|
for (let i = startRow; i < data.length; i++) {
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
// Generate empty header for tables without header
|
// Generate empty header for tables without header
|
||||||
markdown += '| ' + colWidths.map(w => ' '.repeat(w)).join(' | ') + ' |\n';
|
markdown += '| ' + colWidths.map((w) => ' '.repeat(w)).join(' | ') + ' |\n';
|
||||||
} else {
|
} else {
|
||||||
const row = data[i];
|
const row = data[i];
|
||||||
const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
|
const cells = row.map((cell, j) => alignCell(cell, colWidths[j]));
|
||||||
@@ -434,7 +455,7 @@
|
|||||||
|
|
||||||
// Add separator after first row
|
// Add separator after first row
|
||||||
if ((hasHeader && i === 0) || (!hasHeader && i === -1)) {
|
if ((hasHeader && i === 0) || (!hasHeader && i === -1)) {
|
||||||
const separators = colWidths.map(w => getSeparator(w));
|
const separators = colWidths.map((w) => getSeparator(w));
|
||||||
markdown += '| ' + separators.join(' | ') + ' |\n';
|
markdown += '| ' + separators.join(' | ') + ' |\n';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,7 +490,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Quick templates
|
// Quick templates
|
||||||
document.querySelectorAll('.template-chip').forEach(chip => {
|
document.querySelectorAll('.template-chip').forEach((chip) => {
|
||||||
chip.addEventListener('click', () => {
|
chip.addEventListener('click', () => {
|
||||||
currentRows = parseInt(chip.dataset.rows);
|
currentRows = parseInt(chip.dataset.rows);
|
||||||
currentCols = parseInt(chip.dataset.cols);
|
currentCols = parseInt(chip.dataset.cols);
|
||||||
@@ -481,7 +502,7 @@
|
|||||||
|
|
||||||
// Clear all
|
// Clear all
|
||||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
document.getElementById('btn-clear').addEventListener('click', () => {
|
||||||
document.querySelectorAll('#editable-table input').forEach(input => {
|
document.querySelectorAll('#editable-table input').forEach((input) => {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
});
|
});
|
||||||
updatePreview();
|
updatePreview();
|
||||||
@@ -494,7 +515,7 @@
|
|||||||
['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'],
|
['Task 1', 'First task description', 'In Progress', 'High', '2024-01-15'],
|
||||||
['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'],
|
['Task 2', 'Second task description', 'Completed', 'Medium', '2024-01-10'],
|
||||||
['Task 3', 'Third task description', 'Pending', 'Low', '2024-01-20'],
|
['Task 3', 'Third task description', 'Pending', 'Low', '2024-01-20'],
|
||||||
['Task 4', 'Fourth task description', 'In Review', 'High', '2024-01-18']
|
['Task 4', 'Fourth task description', 'In Review', 'High', '2024-01-18'],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < currentRows; i++) {
|
for (let i = 0; i < currentRows; i++) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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) |
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ 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 |
|
||||||
|
|||||||
@@ -3,18 +3,23 @@
|
|||||||
## [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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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 |
|
||||||
@@ -19,7 +19,7 @@ Brief description of what is being compared and why.
|
|||||||
## 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 |
|
||||||
@@ -29,27 +29,31 @@ Brief description of what is being compared and why.
|
|||||||
### 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 |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
**Date:** {{DATE}}
|
**Date:** {{DATE}}
|
||||||
**Attendees:**
|
**Attendees:**
|
||||||
|
|
||||||
- Name 1
|
- Name 1
|
||||||
- Name 2
|
- Name 2
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,18 @@
|
|||||||
## 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 |
|
||||||
@@ -29,26 +31,29 @@
|
|||||||
## 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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const project = require('project-name');
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ------- | ------- | ----------- |
|
||||||
| option1 | true | Description |
|
| option1 | true | Description |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ 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
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ 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
|
||||||
@@ -38,5 +40,5 @@ 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 |
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ 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.
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ModalManager - Unified modal system with accessibility support
|
* ModalManager - Unified modal system with accessibility support
|
||||||
* @version 4.0.0
|
* @version 4.4.5
|
||||||
*/
|
*/
|
||||||
class ModalManager {
|
class ModalManager {
|
||||||
#modal;
|
#modal;
|
||||||
@@ -21,7 +21,7 @@ class ModalManager {
|
|||||||
focusFirst: true,
|
focusFirst: true,
|
||||||
onOpen: null,
|
onOpen: null,
|
||||||
onClose: null,
|
onClose: null,
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
this.#isOpen = false;
|
this.#isOpen = false;
|
||||||
this.#eventListeners = [];
|
this.#eventListeners = [];
|
||||||
@@ -58,7 +58,7 @@ class ModalManager {
|
|||||||
|
|
||||||
// Elements with data-close attribute
|
// Elements with data-close attribute
|
||||||
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
|
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
|
||||||
closeTriggers.forEach(el => {
|
closeTriggers.forEach((el) => {
|
||||||
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
|
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -78,11 +78,12 @@ class ModalManager {
|
|||||||
'select:not([disabled])',
|
'select:not([disabled])',
|
||||||
'textarea:not([disabled])',
|
'textarea:not([disabled])',
|
||||||
'a[href]',
|
'a[href]',
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
].join(', ');
|
].join(', ');
|
||||||
|
|
||||||
return Array.from(this.#modal.querySelectorAll(selector))
|
return Array.from(this.#modal.querySelectorAll(selector)).filter(
|
||||||
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
|
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#trapFocus(e) {
|
#trapFocus(e) {
|
||||||
@@ -198,11 +199,11 @@ class ModalManager {
|
|||||||
|
|
||||||
// Remove keyboard listener
|
// Remove keyboard listener
|
||||||
const keydownListener = this.#eventListeners.find(
|
const keydownListener = this.#eventListeners.find(
|
||||||
l => l.el === document && l.type === 'keydown'
|
(l) => l.el === document && l.type === 'keydown'
|
||||||
);
|
);
|
||||||
if (keydownListener) {
|
if (keydownListener) {
|
||||||
document.removeEventListener('keydown', keydownListener.handler);
|
document.removeEventListener('keydown', keydownListener.handler);
|
||||||
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
|
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore focus
|
// Restore focus
|
||||||
@@ -237,8 +238,8 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in renderer
|
// Export for use in renderer - avoid duplicate declaration
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined' && !window.ModalManager) {
|
||||||
window.ModalManager = ModalManager;
|
window.ModalManager = ModalManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -1,16 +1,18 @@
|
|||||||
function createWelcomeContent(recentFiles = []) {
|
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
||||||
const recentHtml = recentFiles.length
|
const recentHtml = recentFiles.length
|
||||||
? recentFiles.map(f => {
|
? recentFiles
|
||||||
|
.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('')
|
})
|
||||||
|
.join('')
|
||||||
: '<p class="welcome-muted">No recent files</p>';
|
: '<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>
|
||||||
<p class="welcome-version">Version 4.0.0</p>
|
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
|
||||||
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ function createWelcomeContent(recentFiles = []) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="welcome-section">
|
<div class="welcome-section">
|
||||||
<h2>What's New in v4.0.0</h2>
|
<h2>What's New in v4.x</h2>
|
||||||
<ul class="welcome-features">
|
<ul class="welcome-features">
|
||||||
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
|
||||||
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
|
||||||
|
|||||||
Binary file not shown.
+284
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Zen Mode — distraction-free writing mode
|
||||||
|
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
|
||||||
|
* Toggle with F11, exit with Escape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { EditorView, ViewPlugin, Decoration } = require('@codemirror/view');
|
||||||
|
const { RangeSetBuilder } = require('@codemirror/state');
|
||||||
|
|
||||||
|
class ZenMode {
|
||||||
|
/**
|
||||||
|
* @param {import('./renderer').TabManager} tabManager
|
||||||
|
*/
|
||||||
|
constructor(tabManager) {
|
||||||
|
this.tabManager = tabManager;
|
||||||
|
this.active = false;
|
||||||
|
this._hud = null;
|
||||||
|
this._timerInterval = null;
|
||||||
|
this._sessionStart = null;
|
||||||
|
this._extensions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
if (this.active) return;
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
|
document.body.classList.add('zen-mode');
|
||||||
|
|
||||||
|
// Collapse sidebar
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
if (sidebar) sidebar.classList.add('collapsed');
|
||||||
|
|
||||||
|
// Hide preview pane for the active tab
|
||||||
|
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||||
|
if (tab) {
|
||||||
|
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||||
|
if (previewContainer) previewContainer.classList.add('hidden');
|
||||||
|
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||||
|
if (editorPane) editorPane.classList.add('full-width');
|
||||||
|
|
||||||
|
// Add CM6 extensions to the active editor
|
||||||
|
if (tab.editorView) {
|
||||||
|
this._extensions = [typewriterScrollExtension, lineDimmingExtension];
|
||||||
|
// Reconfigure is not straightforward with CM6's immutable state.
|
||||||
|
// Instead we dispatch effects or simply rebuild with new extensions.
|
||||||
|
// Since we cannot hot-swap extensions on an existing EditorView,
|
||||||
|
// we store a reference and the CSS + updateListener handle the rest.
|
||||||
|
// The typewriter + dimming plugins are applied via a compartment approach
|
||||||
|
// would be ideal, but for simplicity we use DOM + updateListener.
|
||||||
|
this._applyTypewriterBehavior(tab.editorView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HUD
|
||||||
|
this._createHUD();
|
||||||
|
|
||||||
|
// Start session timer
|
||||||
|
this._sessionStart = Date.now();
|
||||||
|
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
|
||||||
|
|
||||||
|
// Focus editor
|
||||||
|
if (tab?.editorView) tab.editorView.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
if (!this.active) return;
|
||||||
|
this.active = false;
|
||||||
|
|
||||||
|
document.body.classList.remove('zen-mode');
|
||||||
|
|
||||||
|
// Restore preview pane for the active tab
|
||||||
|
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
|
||||||
|
if (tab) {
|
||||||
|
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
|
||||||
|
if (previewContainer) previewContainer.classList.remove('hidden');
|
||||||
|
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
|
||||||
|
if (editorPane) editorPane.classList.remove('full-width');
|
||||||
|
|
||||||
|
// Remove typewriter listener
|
||||||
|
if (tab.editorView && this._selectionListener) {
|
||||||
|
window.removeEventListener('zen-typewriter', this._selectionListener);
|
||||||
|
this._selectionListener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove HUD
|
||||||
|
if (this._hud && this._hud.parentNode) {
|
||||||
|
this._hud.parentNode.removeChild(this._hud);
|
||||||
|
this._hud = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop timer
|
||||||
|
if (this._timerInterval) {
|
||||||
|
clearInterval(this._timerInterval);
|
||||||
|
this._timerInterval = null;
|
||||||
|
}
|
||||||
|
this._sessionStart = null;
|
||||||
|
this._extensions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.active) {
|
||||||
|
this.deactivate();
|
||||||
|
} else {
|
||||||
|
this.activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyTypewriterBehavior(view) {
|
||||||
|
// We use a custom update listener that scrolls the cursor to center.
|
||||||
|
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
|
||||||
|
scrollFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
_createHUD() {
|
||||||
|
const hud = document.createElement('div');
|
||||||
|
hud.className = 'zen-hud';
|
||||||
|
hud.innerHTML = `
|
||||||
|
<span class="zen-hud-item" id="zen-words">0 words</span>
|
||||||
|
<span class="zen-hud-sep">·</span>
|
||||||
|
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
||||||
|
<span class="zen-hud-sep">·</span>
|
||||||
|
<span class="zen-hud-item" id="zen-timer">00:00</span>
|
||||||
|
<div class="zen-progress-container" id="zen-progress-container">
|
||||||
|
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(hud);
|
||||||
|
this._hud = hud;
|
||||||
|
this._updateHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateHUD() {
|
||||||
|
if (!this.active || !this._hud) return;
|
||||||
|
|
||||||
|
const content = this.tabManager.getEditorContent();
|
||||||
|
const words = content.trim()
|
||||||
|
? content
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 0).length
|
||||||
|
: 0;
|
||||||
|
const readingTime = Math.max(1, Math.ceil(words / 200));
|
||||||
|
|
||||||
|
const wordsEl = document.getElementById('zen-words');
|
||||||
|
const readingEl = document.getElementById('zen-reading-time');
|
||||||
|
const timerEl = document.getElementById('zen-timer');
|
||||||
|
|
||||||
|
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
||||||
|
if (readingEl) readingEl.textContent = `${readingTime} min read`;
|
||||||
|
|
||||||
|
// Session timer
|
||||||
|
if (this._sessionStart) {
|
||||||
|
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
|
||||||
|
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
||||||
|
const seconds = String(elapsed % 60).padStart(2, '0');
|
||||||
|
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word goal progress
|
||||||
|
const goalStr = localStorage.getItem('zen-word-goal');
|
||||||
|
const progressBar = document.getElementById('zen-progress-bar');
|
||||||
|
const progressContainer = document.getElementById('zen-progress-container');
|
||||||
|
if (progressBar && progressContainer) {
|
||||||
|
if (goalStr) {
|
||||||
|
const goal = parseInt(goalStr, 10);
|
||||||
|
if (goal > 0) {
|
||||||
|
const pct = Math.min(100, Math.round((words / goal) * 100));
|
||||||
|
progressBar.style.width = `${pct}%`;
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typewriter scroll ViewPlugin
|
||||||
|
const typewriterScrollExtension = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
constructor(view) {
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update) {
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
const pos = update.state.selection.main.head;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (this.view.dom && this.view.dom.isConnected) {
|
||||||
|
this.view.dispatch({
|
||||||
|
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Line dimming ViewPlugin
|
||||||
|
function getOpacity(distance) {
|
||||||
|
if (distance === 0) return 1.0;
|
||||||
|
if (distance === 1) return 0.7;
|
||||||
|
if (distance === 2) return 0.6;
|
||||||
|
if (distance === 3) return 0.45;
|
||||||
|
return 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineDimmingExtension = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
constructor(view) {
|
||||||
|
this.decorations = this.buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update) {
|
||||||
|
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||||
|
this.decorations = this.buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDecorations(view) {
|
||||||
|
const builder = new RangeSetBuilder();
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const activeLine = view.state.doc.lineAt(pos).number;
|
||||||
|
const doc = view.state.doc;
|
||||||
|
|
||||||
|
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
||||||
|
const line = doc.line(lineNum);
|
||||||
|
const distance = Math.abs(lineNum - activeLine);
|
||||||
|
const opacity = getOpacity(distance);
|
||||||
|
const deco = Decoration.line({
|
||||||
|
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
||||||
|
});
|
||||||
|
builder.add(line.from, line.from, deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = { ZenMode };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const { EventBus } = require('../src/plugins/event-bus');
|
||||||
|
|
||||||
|
describe('EventBus', () => {
|
||||||
|
let bus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new EventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on/emit — listener receives payload', () => {
|
||||||
|
const received = [];
|
||||||
|
bus.on('document:saved', (payload) => received.push(payload));
|
||||||
|
bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' });
|
||||||
|
expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on — ignores events with no listeners', () => {
|
||||||
|
expect(() => bus.emit('unknown:event', {})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off — removes specific listener', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.off('test:event', handler);
|
||||||
|
bus.emit('test:event', {});
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off — removes all listeners for event when no handler given', () => {
|
||||||
|
const h1 = jest.fn();
|
||||||
|
const h2 = jest.fn();
|
||||||
|
bus.on('test:event', h1);
|
||||||
|
bus.on('test:event', h2);
|
||||||
|
bus.off('test:event');
|
||||||
|
bus.emit('test:event', {});
|
||||||
|
expect(h1).not.toHaveBeenCalled();
|
||||||
|
expect(h2).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasHandler — returns true when listener exists', () => {
|
||||||
|
bus.on('ai:analyze', () => {});
|
||||||
|
expect(bus.hasHandler('ai:analyze')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasHandler — returns false when no listener exists', () => {
|
||||||
|
expect(bus.hasHandler('ai:analyze')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handler errors are caught and logged, not thrown', () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
bus.on('bad:event', () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple listeners all receive the event', () => {
|
||||||
|
const results = [];
|
||||||
|
bus.on('multi:event', () => results.push('a'));
|
||||||
|
bus.on('multi:event', () => results.push('b'));
|
||||||
|
bus.emit('multi:event', {});
|
||||||
|
expect(results).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Git Operations Utilities Test Suite
|
||||||
|
* Tests for async patterns and error handling
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('GitOperations Utilities', () => {
|
||||||
|
describe('error handling patterns', () => {
|
||||||
|
it('should handle git errors gracefully', () => {
|
||||||
|
const errorResponse = { error: 'Not a git repository' };
|
||||||
|
|
||||||
|
expect(errorResponse).toHaveProperty('error');
|
||||||
|
expect(errorResponse.error).toContain('repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object on failure', () => {
|
||||||
|
const failureResult = { error: 'Failed to commit' };
|
||||||
|
|
||||||
|
expect(failureResult).toBeDefined();
|
||||||
|
expect(failureResult.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('async patterns', () => {
|
||||||
|
it('should handle async operations', async () => {
|
||||||
|
const asyncFn = async () => {
|
||||||
|
return { status: 'success' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await asyncFn();
|
||||||
|
expect(result).toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async errors', async () => {
|
||||||
|
const asyncFnWithError = async () => {
|
||||||
|
throw new Error('Git operation failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFnWithError();
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.message).toContain('Git');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git operations', () => {
|
||||||
|
it('should document expected operations', () => {
|
||||||
|
const gitOps = [
|
||||||
|
'getStatus',
|
||||||
|
'stage',
|
||||||
|
'commit',
|
||||||
|
'log',
|
||||||
|
'diff',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'push',
|
||||||
|
'pull',
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(gitOps.length).toBeGreaterThan(0);
|
||||||
|
gitOps.forEach((op) => {
|
||||||
|
expect(typeof op).toBe('string');
|
||||||
|
expect(op.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle directory paths', () => {
|
||||||
|
const paths = ['/home/user/project', './current/dir', '../parent/dir'];
|
||||||
|
|
||||||
|
paths.forEach((pathStr) => {
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
expect(pathStr.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle commit messages', () => {
|
||||||
|
const messages = [
|
||||||
|
'fix: bug in git panel',
|
||||||
|
'feat: add new feature',
|
||||||
|
'refactor: clean up code',
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.forEach((msg) => {
|
||||||
|
expect(typeof msg).toBe('string');
|
||||||
|
expect(msg.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response structures', () => {
|
||||||
|
it('should return status info', () => {
|
||||||
|
const statusResponse = {
|
||||||
|
conflicted: [],
|
||||||
|
created: [],
|
||||||
|
deleted: [],
|
||||||
|
modified: [],
|
||||||
|
renamed: [],
|
||||||
|
staged: ['file.md'],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(statusResponse).toHaveProperty('staged');
|
||||||
|
expect(Array.isArray(statusResponse.staged)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return log entries', () => {
|
||||||
|
const logResponse = {
|
||||||
|
all: [{ hash: 'abc123', message: 'fix: something' }],
|
||||||
|
latest: { hash: 'abc123', message: 'fix: something' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(logResponse).toHaveProperty('all');
|
||||||
|
expect(Array.isArray(logResponse.all)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
|
||||||
|
|
||||||
|
describe('GoalTracker', () => {
|
||||||
|
let tracker;
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {};
|
||||||
|
tracker = new GoalTracker({
|
||||||
|
get: (key) => store[key],
|
||||||
|
set: (key, value) => {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addWords records words for today', () => {
|
||||||
|
tracker.addWords(500);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
expect(store['plugins.writing-studio.history']).toBeDefined();
|
||||||
|
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||||
|
expect(history[today].words).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addWords accumulates across multiple calls', () => {
|
||||||
|
tracker.addWords(300);
|
||||||
|
tracker.addWords(200);
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const history = JSON.parse(store['plugins.writing-studio.history']);
|
||||||
|
expect(history[today].words).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDailyProgress returns 0 when no history', () => {
|
||||||
|
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDailyProgress returns percentage', () => {
|
||||||
|
tracker.addWords(500);
|
||||||
|
const progress = tracker.getDailyProgress(1000);
|
||||||
|
expect(progress.written).toBe(500);
|
||||||
|
expect(progress.pct).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStreak counts consecutive days meeting goal', () => {
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const key = d.toISOString().split('T')[0];
|
||||||
|
tracker._setHistoryDay(key, { words: 1200 });
|
||||||
|
}
|
||||||
|
const streak = tracker.getStreak(1000);
|
||||||
|
expect(streak).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStreak breaks on missed day', () => {
|
||||||
|
const today = new Date();
|
||||||
|
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
|
||||||
|
const dayBefore = new Date(today);
|
||||||
|
dayBefore.setDate(dayBefore.getDate() - 2);
|
||||||
|
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
|
||||||
|
expect(tracker.getStreak(1000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLast30Days returns array of 30 entries', () => {
|
||||||
|
tracker.addWords(100);
|
||||||
|
const days = tracker.getLast30Days();
|
||||||
|
expect(days.length).toBe(30);
|
||||||
|
expect(days[29].words).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getWeeklyTotal sums last 7 days', () => {
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
|
||||||
|
}
|
||||||
|
expect(tracker.getWeeklyTotal()).toBe(1400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Tests ensuring the codebase has no ESLint warnings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('ESLint quality gate', () => {
|
||||||
|
test('src and tests have no ESLint warnings', () => {
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const result = execSync('npm run lint', {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
expect(result).not.toMatch(/warning/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,13 +12,11 @@ describe('sanitizeErrorMessage', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('strips Windows absolute paths', () => {
|
test('strips Windows absolute paths', () => {
|
||||||
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js'))
|
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')).toBe('Error in file.js');
|
||||||
.toBe('Error in file.js');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('strips Unix absolute paths', () => {
|
test('strips Unix absolute paths', () => {
|
||||||
expect(sanitizeErrorMessage('Error in /home/user/project/file.js'))
|
expect(sanitizeErrorMessage('Error in /home/user/project/file.js')).toBe('Error in file.js');
|
||||||
.toBe('Error in file.js');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles non-string input', () => {
|
test('handles non-string input', () => {
|
||||||
@@ -27,18 +25,19 @@ describe('sanitizeErrorMessage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('preserves messages without paths', () => {
|
test('preserves messages without paths', () => {
|
||||||
expect(sanitizeErrorMessage('Something went wrong'))
|
expect(sanitizeErrorMessage('Something went wrong')).toBe('Something went wrong');
|
||||||
.toBe('Something went wrong');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('strips nested Windows paths', () => {
|
test('strips nested Windows paths', () => {
|
||||||
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json'))
|
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')).toBe(
|
||||||
.toBe('Cannot read config.json');
|
'Cannot read config.json'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('strips nested Unix paths', () => {
|
test('strips nested Unix paths', () => {
|
||||||
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log'))
|
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')).toBe(
|
||||||
.toBe('File not found: error.log');
|
'File not found: error.log'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles undefined input', () => {
|
test('handles undefined input', () => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('Markdown Extensions', () => {
|
|||||||
|
|
||||||
test('matches all admonition types', () => {
|
test('matches all admonition types', () => {
|
||||||
const types = ['note', 'warning', 'tip', 'danger', 'info'];
|
const types = ['note', 'warning', 'tip', 'danger', 'info'];
|
||||||
types.forEach(type => {
|
types.forEach((type) => {
|
||||||
const src = `:::${type}\nContent\n:::\n`;
|
const src = `:::${type}\nContent\n:::\n`;
|
||||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
expect(match).not.toBeNull();
|
expect(match).not.toBeNull();
|
||||||
@@ -76,10 +76,81 @@ describe('Markdown Extensions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||||
|
const extension = {
|
||||||
|
name: 'admonition',
|
||||||
|
level: 'block',
|
||||||
|
start(src) {
|
||||||
|
return src.match(/^:::(note|warning|tip|danger|info)/m)?.index;
|
||||||
|
},
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
|
if (match && match.index === 0) {
|
||||||
|
const admonitionType = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
const tokens = [];
|
||||||
|
this.lexer.blockTokens(text, tokens);
|
||||||
|
return {
|
||||||
|
type: 'admonition',
|
||||||
|
raw: match[0],
|
||||||
|
admonitionType,
|
||||||
|
text,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token) {
|
||||||
|
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||||
|
const icon = icons[token.admonitionType] || 'ℹ';
|
||||||
|
const inner = this.parser.parse(token.tokens || []);
|
||||||
|
return `<div class="admonition admonition-${token.admonitionType}">
|
||||||
|
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||||
|
<div class="admonition-content">${inner}</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||||
|
const src = ':::note\nThis is a note.\n:::';
|
||||||
|
const mockLexer = {
|
||||||
|
blockTokens: jest.fn((text, tokens) => {
|
||||||
|
tokens.push({ type: 'text', text });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const context = { lexer: mockLexer };
|
||||||
|
const result = extension.tokenizer.call(context, src);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.type).toBe('admonition');
|
||||||
|
expect(result.admonitionType).toBe('note');
|
||||||
|
expect(result.text).toBe('This is a note.');
|
||||||
|
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||||
|
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer correctly translates tokens to HTML', () => {
|
||||||
|
const token = {
|
||||||
|
type: 'admonition',
|
||||||
|
admonitionType: 'warning',
|
||||||
|
tokens: [{ type: 'text', text: 'Be careful!' }],
|
||||||
|
};
|
||||||
|
const mockParser = {
|
||||||
|
parse: jest.fn(() => '<p>Be careful!</p>'),
|
||||||
|
};
|
||||||
|
const context = { parser: mockParser };
|
||||||
|
const html = extension.renderer.call(context, token);
|
||||||
|
|
||||||
|
expect(html).toContain('admonition admonition-warning');
|
||||||
|
expect(html).toContain('⚠ Warning');
|
||||||
|
expect(html).toContain('<p>Be careful!</p>');
|
||||||
|
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PlantUML hex encoding', () => {
|
describe('PlantUML hex encoding', () => {
|
||||||
const plantumlEncode = (text) => {
|
const plantumlEncode = (text) => {
|
||||||
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
||||||
.map(b => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
return '~h' + hex;
|
return '~h' + hex;
|
||||||
};
|
};
|
||||||
@@ -127,4 +198,65 @@ describe('Markdown Extensions', () => {
|
|||||||
expect(slugify('simple')).toBe('simple');
|
expect(slugify('simple')).toBe('simple');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scopeCSS utility', () => {
|
||||||
|
const scopeCSS = (cssText, scopeSelector) => {
|
||||||
|
if (!cssText) return '';
|
||||||
|
return cssText.replace(
|
||||||
|
/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g,
|
||||||
|
(match, selector, separator) => {
|
||||||
|
const trimmed = selector.trim();
|
||||||
|
if (
|
||||||
|
!trimmed ||
|
||||||
|
trimmed.startsWith('@') ||
|
||||||
|
trimmed.startsWith(':root') ||
|
||||||
|
trimmed.startsWith('from') ||
|
||||||
|
trimmed.startsWith('to') ||
|
||||||
|
/^\d+%$/.test(trimmed)
|
||||||
|
) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('scopes standard tag selector', () => {
|
||||||
|
const css = 'h1 { color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes multiple class selectors', () => {
|
||||||
|
const css = '.title, .content { font-family: sans-serif; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe(
|
||||||
|
'.preview-content .title,.preview-content .content{ font-family: sans-serif; }'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores @rules like @media', () => {
|
||||||
|
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('@media (max-width: 600px)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores :root selector', () => {
|
||||||
|
const css = ':root { --color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe(':root { --color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores keyframe percentages', () => {
|
||||||
|
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('0%');
|
||||||
|
expect(scoped).toContain('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty input', () => {
|
||||||
|
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||||
|
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Tests ensuring production code does not contain leftover diagnostic console.log statements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('No leftover diagnostic console.log statements', () => {
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const filesToCheck = ['src/main.js', 'src/renderer.js'];
|
||||||
|
|
||||||
|
filesToCheck.forEach((relativePath) => {
|
||||||
|
test(`${relativePath} has no console.log statements`, () => {
|
||||||
|
const content = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||||
|
const matches = content.match(/console\.log\(/g) || [];
|
||||||
|
expect(matches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* PDF Operations Utilities Test Suite
|
||||||
|
* Tests for helper functions and logic patterns
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('PDFOperations Utilities', () => {
|
||||||
|
describe('page range parsing', () => {
|
||||||
|
it('should parse single page numbers', () => {
|
||||||
|
// Test logic: parsing "1" should extract page index 0
|
||||||
|
const pages = [0]; // Parsed result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(1);
|
||||||
|
expect(pages[0]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse page ranges', () => {
|
||||||
|
// Test logic: parsing "1-3" should extract pages 0, 1, 2
|
||||||
|
const pages = [0, 1, 2]; // Expected result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(3);
|
||||||
|
expect(pages).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple ranges', () => {
|
||||||
|
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
|
||||||
|
const pages = [0, 1, 3, 4]; // Expected result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(4);
|
||||||
|
expect(pages).toEqual([0, 1, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort pages in ascending order', () => {
|
||||||
|
const unsorted = [2, 0, 3, 1];
|
||||||
|
const sorted = unsorted.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
expect(sorted).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hex color conversion', () => {
|
||||||
|
it('should validate hex color format', () => {
|
||||||
|
const validHex = '#FF5733';
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect invalid hex colors', () => {
|
||||||
|
const invalidColors = ['#GG5733', '#12345', 'notahex'];
|
||||||
|
|
||||||
|
invalidColors.forEach((color) => {
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle empty input', () => {
|
||||||
|
const input = '';
|
||||||
|
expect(input.length).toBe(0);
|
||||||
|
// Should not process empty strings
|
||||||
|
expect(input === '').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid page numbers', () => {
|
||||||
|
const testPages = [
|
||||||
|
{ input: '0', isValid: false },
|
||||||
|
{ input: '-1', isValid: false },
|
||||||
|
{ input: 'abc', isValid: false },
|
||||||
|
{ input: '1', isValid: true },
|
||||||
|
{ input: '5', isValid: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
testPages.forEach(({ input, isValid }) => {
|
||||||
|
const num = parseInt(input);
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
// Non-numeric string
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else if (num < 1) {
|
||||||
|
// Zero or negative
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else {
|
||||||
|
// Positive number
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||||
|
|
||||||
|
describe('PluginAPI', () => {
|
||||||
|
test('has default no-op lifecycle methods', () => {
|
||||||
|
const plugin = new PluginAPI();
|
||||||
|
expect(() => plugin.init({})).not.toThrow();
|
||||||
|
expect(() => plugin.activate()).not.toThrow();
|
||||||
|
expect(() => plugin.deactivate()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getManifest returns null by default', () => {
|
||||||
|
const plugin = new PluginAPI();
|
||||||
|
expect(plugin.getManifest()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subclass can override init', () => {
|
||||||
|
let called = false;
|
||||||
|
class MyPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
called = true;
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const p = new MyPlugin();
|
||||||
|
p.init({ foo: 'bar' });
|
||||||
|
expect(called).toBe(true);
|
||||||
|
expect(p.context.foo).toBe('bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
const { PluginContext } = require('../src/plugins/plugin-context');
|
||||||
|
const { EventBus } = require('../src/plugins/event-bus');
|
||||||
|
|
||||||
|
describe('PluginContext', () => {
|
||||||
|
let context;
|
||||||
|
let mockDeps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeps = {
|
||||||
|
pluginId: 'test-plugin',
|
||||||
|
sidebar: { registerPanel: jest.fn() },
|
||||||
|
commands: { register: jest.fn() },
|
||||||
|
statusBar: { registerIndicator: jest.fn() },
|
||||||
|
eventBus: new EventBus(),
|
||||||
|
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||||
|
editor: {
|
||||||
|
getContent: jest.fn(),
|
||||||
|
getSelection: jest.fn(),
|
||||||
|
insertAtCursor: jest.fn(),
|
||||||
|
onContentChanged: jest.fn(),
|
||||||
|
},
|
||||||
|
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||||
|
exportHooks: { preHooks: [], postHooks: [] },
|
||||||
|
};
|
||||||
|
context = new PluginContext(mockDeps);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||||
|
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', {
|
||||||
|
icon: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
render: handler,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes commands.register with crash-safe wrapper', () => {
|
||||||
|
const badHandler = () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
};
|
||||||
|
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||||
|
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||||
|
expect(() => registeredHandler()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes settings.get/set scoped to plugin', () => {
|
||||||
|
context.settings.get('myKey');
|
||||||
|
context.settings.set('myKey', 'myValue');
|
||||||
|
expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey');
|
||||||
|
expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes editor methods', () => {
|
||||||
|
context.editor.getContent();
|
||||||
|
context.editor.getSelection();
|
||||||
|
expect(mockDeps.editor.getContent).toHaveBeenCalled();
|
||||||
|
expect(mockDeps.editor.getSelection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes events via event bus', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.events.on('test:event', handler);
|
||||||
|
context.events.emit('test:event', { data: 1 });
|
||||||
|
expect(handler).toHaveBeenCalledWith({ data: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes events.hasHandler', () => {
|
||||||
|
expect(context.events.hasHandler('no:such')).toBe(false);
|
||||||
|
context.events.on('exists:event', () => {});
|
||||||
|
expect(context.events.hasHandler('exists:event')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes ipc.invoke', () => {
|
||||||
|
context.ipc.invoke('test:channel', { a: 1 });
|
||||||
|
expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes exports.registerPreHook', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.exports.registerPreHook(handler);
|
||||||
|
expect(mockDeps.exportHooks.preHooks).toContain(handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes exports.registerPostHook', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.exports.registerPostHook(handler);
|
||||||
|
expect(mockDeps.exportHooks.postHooks).toContain(handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const { PluginLoader } = require('../src/plugins/plugin-loader');
|
||||||
|
|
||||||
|
describe('PluginLoader', () => {
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeManifest(dir, manifest) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('discoverPlugins — finds manifests in built-in directory', () => {
|
||||||
|
const pluginDir = path.join(tempDir, 'my-plugin');
|
||||||
|
writeManifest(pluginDir, {
|
||||||
|
id: 'my-plugin',
|
||||||
|
name: 'My Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Test',
|
||||||
|
});
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].id).toBe('my-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — skips directories without manifest.json', () => {
|
||||||
|
const emptyDir = path.join(tempDir, 'no-manifest');
|
||||||
|
fs.mkdirSync(emptyDir, { recursive: true });
|
||||||
|
const loader = new PluginLoader([emptyDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — accepts valid manifest', () => {
|
||||||
|
const manifest = {
|
||||||
|
id: 'test-plugin',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A test plugin',
|
||||||
|
};
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(loader.validateManifest(manifest)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest missing id', () => {
|
||||||
|
const manifest = { name: 'No ID', version: '1.0.0', description: 'x' };
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(() => loader.validateManifest(manifest)).toThrow(/id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest missing name', () => {
|
||||||
|
const manifest = { id: 'test', version: '1.0.0', description: 'x' };
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(() => loader.validateManifest(manifest)).toThrow(/name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
loader.loadedIds = new Set(['existing-plugin']);
|
||||||
|
expect(() =>
|
||||||
|
loader.validateManifest({
|
||||||
|
id: 'existing-plugin',
|
||||||
|
name: 'Dup',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'x',
|
||||||
|
})
|
||||||
|
).toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||||
|
const pluginDir = path.join(tempDir, 'with-index');
|
||||||
|
writeManifest(pluginDir, {
|
||||||
|
id: 'with-index',
|
||||||
|
name: 'With Index',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Test',
|
||||||
|
});
|
||||||
|
// Write a simple index.js with a Plugin export
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginDir, 'index.js'),
|
||||||
|
`
|
||||||
|
class SimplePlugin { init() {} }
|
||||||
|
module.exports = { Plugin: SimplePlugin };
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].PluginClass).toBeDefined();
|
||||||
|
expect(plugins[0].PluginClass.name).toBe('SimplePlugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — continues if one plugin fails to load', () => {
|
||||||
|
const badDir = path.join(tempDir, 'bad');
|
||||||
|
writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' });
|
||||||
|
fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");');
|
||||||
|
|
||||||
|
const goodDir = path.join(tempDir, 'good');
|
||||||
|
writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' });
|
||||||
|
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].id).toBe('good');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const { PluginRegistry } = require('../src/plugins/plugin-registry');
|
||||||
|
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||||
|
const { EventBus } = require('../src/plugins/event-bus');
|
||||||
|
|
||||||
|
class TestPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.initialized = true;
|
||||||
|
this.ctx = context;
|
||||||
|
}
|
||||||
|
activate() {
|
||||||
|
this.activated = true;
|
||||||
|
}
|
||||||
|
deactivate() {
|
||||||
|
this.deactivated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PluginRegistry', () => {
|
||||||
|
let registry;
|
||||||
|
let mockDeps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeps = {
|
||||||
|
sidebar: { registerPanel: jest.fn() },
|
||||||
|
commands: { register: jest.fn() },
|
||||||
|
statusBar: { registerIndicator: jest.fn() },
|
||||||
|
eventBus: new EventBus(),
|
||||||
|
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||||
|
editor: {
|
||||||
|
getContent: jest.fn(() => ''),
|
||||||
|
getSelection: jest.fn(() => ''),
|
||||||
|
insertAtCursor: jest.fn(),
|
||||||
|
onContentChanged: jest.fn(),
|
||||||
|
},
|
||||||
|
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||||
|
};
|
||||||
|
registry = new PluginRegistry(mockDeps);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('register — stores plugin and calls init', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'desc',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: TestPlugin,
|
||||||
|
dir: '/tmp/test',
|
||||||
|
});
|
||||||
|
const entry = registry.getPlugin('test');
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.instance.initialized).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('register — works without PluginClass', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'manifest-only',
|
||||||
|
name: 'Manifest Only',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'desc',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: null,
|
||||||
|
dir: '/tmp/test',
|
||||||
|
});
|
||||||
|
expect(registry.getPlugin('manifest-only')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('register — init error does not crash, plugin not registered', () => {
|
||||||
|
class BadPlugin extends PluginAPI {
|
||||||
|
init() {
|
||||||
|
throw new Error('init fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(() => {
|
||||||
|
registry.register({
|
||||||
|
id: 'bad',
|
||||||
|
name: 'Bad',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'desc',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: BadPlugin,
|
||||||
|
dir: '/tmp/test',
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(registry.getPlugin('bad')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlugin — returns undefined for unknown', () => {
|
||||||
|
expect(registry.getPlugin('nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAll — returns all registered plugins', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'a',
|
||||||
|
name: 'A',
|
||||||
|
version: '1',
|
||||||
|
description: '',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: null,
|
||||||
|
dir: '',
|
||||||
|
});
|
||||||
|
registry.register({
|
||||||
|
id: 'b',
|
||||||
|
name: 'B',
|
||||||
|
version: '1',
|
||||||
|
description: '',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: null,
|
||||||
|
dir: '',
|
||||||
|
});
|
||||||
|
expect(registry.getAll()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate — calls activate on plugin instance', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
version: '1',
|
||||||
|
description: '',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: TestPlugin,
|
||||||
|
dir: '',
|
||||||
|
});
|
||||||
|
registry.activate('test');
|
||||||
|
expect(registry.getPlugin('test').instance.activated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate — calls deactivate on plugin instance', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
version: '1',
|
||||||
|
description: '',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: TestPlugin,
|
||||||
|
dir: '',
|
||||||
|
});
|
||||||
|
registry.deactivate('test');
|
||||||
|
expect(registry.getPlugin('test').instance.deactivated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportHooks are available and populated', () => {
|
||||||
|
registry.register({
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
version: '1',
|
||||||
|
description: '',
|
||||||
|
manifest: {},
|
||||||
|
PluginClass: TestPlugin,
|
||||||
|
dir: '',
|
||||||
|
});
|
||||||
|
const handler = jest.fn();
|
||||||
|
registry.getPlugin('test').instance.ctx.exports.registerPreHook(handler);
|
||||||
|
expect(registry.exportHooks.preHooks).toContain(handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-2
@@ -54,6 +54,14 @@ describe('Preload Security', () => {
|
|||||||
'save-pasted-image',
|
'save-pasted-image',
|
||||||
'load-template',
|
'load-template',
|
||||||
'list-directory',
|
'list-directory',
|
||||||
|
'read-file',
|
||||||
|
'write-file',
|
||||||
|
'delete-file',
|
||||||
|
'ensure-directory',
|
||||||
|
'path-exists',
|
||||||
|
'is-directory',
|
||||||
|
'copy-path',
|
||||||
|
'move-path',
|
||||||
'open-file-path',
|
'open-file-path',
|
||||||
'git-status',
|
'git-status',
|
||||||
'git-stage',
|
'git-stage',
|
||||||
@@ -66,7 +74,7 @@ describe('Preload Security', () => {
|
|||||||
'execute-code',
|
'execute-code',
|
||||||
'show-pdf-editor-from-toolbar',
|
'show-pdf-editor-from-toolbar',
|
||||||
'menu-open',
|
'menu-open',
|
||||||
'export'
|
'export',
|
||||||
];
|
];
|
||||||
|
|
||||||
const EXPECTED_RECEIVE_CHANNELS = [
|
const EXPECTED_RECEIVE_CHANNELS = [
|
||||||
@@ -119,7 +127,7 @@ describe('Preload Security', () => {
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel'
|
'toggle-bottom-panel',
|
||||||
];
|
];
|
||||||
|
|
||||||
test('should define all expected send channels', () => {
|
test('should define all expected send channels', () => {
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
describe('PrintPreview', () => {
|
describe('PrintPreview', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock electron require for executePrint
|
// Mock electron require for executePrint
|
||||||
jest.mock('electron', () => ({
|
jest.mock(
|
||||||
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
|
'electron',
|
||||||
}), { virtual: true });
|
() => ({
|
||||||
|
ipcRenderer: { send: jest.fn(), invoke: jest.fn() },
|
||||||
|
}),
|
||||||
|
{ virtual: true }
|
||||||
|
);
|
||||||
|
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div class="dialog-overlay hidden" id="print-preview-overlay">
|
<div class="dialog-overlay hidden" id="print-preview-overlay">
|
||||||
@@ -51,9 +55,13 @@ describe('PrintPreview', () => {
|
|||||||
const { PrintPreview } = require('../src/print-preview');
|
const { PrintPreview } = require('../src/print-preview');
|
||||||
const preview = new PrintPreview();
|
const preview = new PrintPreview();
|
||||||
preview.open('<p>Test</p>');
|
preview.open('<p>Test</p>');
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false);
|
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
preview.close();
|
preview.close();
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateScaleLabel reflects slider value', () => {
|
test('updateScaleLabel reflects slider value', () => {
|
||||||
@@ -69,7 +77,9 @@ describe('PrintPreview', () => {
|
|||||||
const preview = new PrintPreview();
|
const preview = new PrintPreview();
|
||||||
preview.open('<p>Test</p>');
|
preview.open('<p>Test</p>');
|
||||||
document.getElementById('print-preview-close').click();
|
document.getElementById('print-preview-close').click();
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cancel button closes preview', () => {
|
test('cancel button closes preview', () => {
|
||||||
@@ -77,7 +87,9 @@ describe('PrintPreview', () => {
|
|||||||
const preview = new PrintPreview();
|
const preview = new PrintPreview();
|
||||||
preview.open('<p>Test</p>');
|
preview.open('<p>Test</p>');
|
||||||
document.getElementById('print-cancel').click();
|
document.getElementById('print-cancel').click();
|
||||||
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
|
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getOptions reflects changed values', () => {
|
test('getOptions reflects changed values', () => {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||||
|
|
||||||
|
describe('ProjectManager', () => {
|
||||||
|
let pm;
|
||||||
|
let files;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
files = {};
|
||||||
|
pm = new ProjectManager({
|
||||||
|
readFile: (p) => files[p] || null,
|
||||||
|
writeFile: (p, c) => {
|
||||||
|
files[p] = c;
|
||||||
|
},
|
||||||
|
fileExists: (p) => p in files,
|
||||||
|
listDir: (p) =>
|
||||||
|
Object.keys(files)
|
||||||
|
.filter((f) => f.startsWith(p))
|
||||||
|
.map((f) => f.slice(p.length + 1)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject writes .project.json', () => {
|
||||||
|
const project = pm.createProject('/manuscripts/novel', {
|
||||||
|
title: 'My Novel',
|
||||||
|
type: 'manuscript',
|
||||||
|
targetWords: 80000,
|
||||||
|
});
|
||||||
|
expect(project.title).toBe('My Novel');
|
||||||
|
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.target.words).toBe(80000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProject reads and returns project data', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
const project = pm.loadProject('/manuscripts/novel');
|
||||||
|
expect(project.title).toBe('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProject returns null if no project file', () => {
|
||||||
|
expect(pm.loadProject('/nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addChapter appends chapter and saves', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
pm.addChapter('/manuscripts/novel', {
|
||||||
|
file: '01-chapter.md',
|
||||||
|
title: 'Chapter One',
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.chapters.length).toBe(1);
|
||||||
|
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileManuscript concatenates chapter files', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||||
|
],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||||
|
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||||
|
const result = pm.compileManuscript('/manuscripts/novel');
|
||||||
|
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileManuscript skips missing files', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||||
|
],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||||
|
const result = pm.compileManuscript('/manuscripts/novel');
|
||||||
|
expect(result).toBe('Only chapter one.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStats returns total word count across chapters', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' },
|
||||||
|
],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||||
|
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||||
|
const stats = pm.getStats('/manuscripts/novel');
|
||||||
|
expect(stats.totalWords).toBeGreaterThan(0);
|
||||||
|
expect(stats.chapterCount).toBe(2);
|
||||||
|
expect(stats.targetWords).toBe(50000);
|
||||||
|
expect(stats.pctComplete).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateChapter modifies a chapter by index', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test',
|
||||||
|
type: 'manuscript',
|
||||||
|
target: { words: 50000 },
|
||||||
|
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.chapters[0].title).toBe('New Title');
|
||||||
|
expect(parsed.chapters[0].status).toBe('revised');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Tests for project metadata consistency
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('Project version consistency', () => {
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
|
||||||
|
const readme = fs.readFileSync(path.join(rootDir, 'README.md'), 'utf8');
|
||||||
|
|
||||||
|
test('README version matches package.json version', () => {
|
||||||
|
const version = packageJson.version;
|
||||||
|
expect(readme).toContain(`v${version}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('README contains current version string', () => {
|
||||||
|
expect(readme).toContain(`v${packageJson.version}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Security: Path Traversal Prevention Test Suite
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('Security: Path Handling', () => {
|
||||||
|
describe('path traversal prevention', () => {
|
||||||
|
it('should detect path traversal patterns', () => {
|
||||||
|
const maliciousPaths = ['../etc/passwd', '../../sensitive', './../outside'];
|
||||||
|
|
||||||
|
maliciousPaths.forEach((pathStr) => {
|
||||||
|
// Path traversal attempts contain .. patterns
|
||||||
|
expect(pathStr).toMatch(/\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize relative paths safely', () => {
|
||||||
|
const safePaths = ['./documents/file.md', 'relative/path/file.txt'];
|
||||||
|
|
||||||
|
safePaths.forEach((pathStr) => {
|
||||||
|
const normalized = path.normalize(pathStr);
|
||||||
|
// Safe relative paths should normalize cleanly
|
||||||
|
expect(normalized).toBeDefined();
|
||||||
|
expect(typeof normalized).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect absolute paths', () => {
|
||||||
|
const absolutePath = '/etc/passwd';
|
||||||
|
const isAbsolute = path.isAbsolute(absolutePath);
|
||||||
|
|
||||||
|
// Linux/Mac: /path is absolute
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(isAbsolute).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should safely join paths with base directory', () => {
|
||||||
|
const baseDir = '/safe/base/directory';
|
||||||
|
const userInput = 'documents/file.md';
|
||||||
|
|
||||||
|
const joined = path.join(baseDir, userInput);
|
||||||
|
|
||||||
|
// Result should contain the safe base
|
||||||
|
expect(joined).toContain('base');
|
||||||
|
expect(joined).toContain('documents');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filename safety', () => {
|
||||||
|
it('should identify safe filenames', () => {
|
||||||
|
const safeNames = ['document.md', 'my-file.txt', 'file_name.pdf', 'report_2026_04_24.xlsx'];
|
||||||
|
|
||||||
|
safeNames.forEach((name) => {
|
||||||
|
// Safe names should not contain path separators or null bytes
|
||||||
|
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
|
||||||
|
expect(isSafe).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag filenames with path separators', () => {
|
||||||
|
const problematicNames = ['file/with/slashes.txt', 'file\\with\\backslashes.txt'];
|
||||||
|
|
||||||
|
problematicNames.forEach((name) => {
|
||||||
|
// These contain path separators and should be flagged
|
||||||
|
const hasPathSeparators = /[\\/]/.test(name);
|
||||||
|
expect(hasPathSeparators).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce minimum filename length', () => {
|
||||||
|
const emptyName = '';
|
||||||
|
|
||||||
|
expect(emptyName.length).toBe(0);
|
||||||
|
expect(emptyName.length > 0).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation patterns', () => {
|
||||||
|
it('should validate path existence check pattern', () => {
|
||||||
|
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||||
|
validPaths.forEach((pathStr) => {
|
||||||
|
// These should match a reasonable filename pattern
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent null byte injection', () => {
|
||||||
|
const pathWithNullByte = 'file.txt\0.exe';
|
||||||
|
|
||||||
|
const isSafe = !pathWithNullByte.includes('\0');
|
||||||
|
expect(isSafe).toBe(false); // Has null byte, not safe
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const { SettingsStore } = require('../src/plugins/settings-store');
|
||||||
|
|
||||||
|
describe('SettingsStore', () => {
|
||||||
|
let store;
|
||||||
|
let data;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
data = {};
|
||||||
|
store = new SettingsStore({
|
||||||
|
get: (key) => data[key],
|
||||||
|
set: (key, value) => {
|
||||||
|
data[key] = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns value for full key', () => {
|
||||||
|
data['plugins.my-plugin.myKey'] = 'myValue';
|
||||||
|
expect(store.get('plugins.my-plugin.myKey')).toBe('myValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns undefined for missing key', () => {
|
||||||
|
expect(store.get('plugins.my-plugin.missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set stores value', () => {
|
||||||
|
store.set('plugins.my-plugin.myKey', 42);
|
||||||
|
expect(data['plugins.my-plugin.myKey']).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set overwrites existing value', () => {
|
||||||
|
data['plugins.my-plugin.myKey'] = 'old';
|
||||||
|
store.set('plugins.my-plugin.myKey', 'new');
|
||||||
|
expect(data['plugins.my-plugin.myKey']).toBe('new');
|
||||||
|
});
|
||||||
|
});
|
||||||
+23
-15
@@ -17,61 +17,69 @@ global.window.electronAPI = {
|
|||||||
setCurrent: jest.fn(),
|
setCurrent: jest.fn(),
|
||||||
saveRecent: jest.fn(),
|
saveRecent: jest.fn(),
|
||||||
clearRecent: jest.fn(),
|
clearRecent: jest.fn(),
|
||||||
rendererReady: jest.fn()
|
rendererReady: jest.fn(),
|
||||||
|
read: jest.fn(() => Promise.resolve('')),
|
||||||
|
write: jest.fn(() => Promise.resolve()),
|
||||||
|
delete: jest.fn(() => Promise.resolve()),
|
||||||
|
ensureDir: jest.fn(() => Promise.resolve()),
|
||||||
|
exists: jest.fn(() => Promise.resolve(false)),
|
||||||
|
isDirectory: jest.fn(() => Promise.resolve(false)),
|
||||||
|
copy: jest.fn(() => Promise.resolve()),
|
||||||
|
move: jest.fn(() => Promise.resolve()),
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
get: jest.fn()
|
get: jest.fn(),
|
||||||
},
|
},
|
||||||
print: {
|
print: {
|
||||||
doPrint: jest.fn()
|
doPrint: jest.fn(),
|
||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
withOptions: jest.fn(),
|
withOptions: jest.fn(),
|
||||||
spreadsheet: jest.fn()
|
spreadsheet: jest.fn(),
|
||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
convert: jest.fn(),
|
convert: jest.fn(),
|
||||||
selectFolder: jest.fn()
|
selectFolder: jest.fn(),
|
||||||
},
|
},
|
||||||
converter: {
|
converter: {
|
||||||
convert: jest.fn(),
|
convert: jest.fn(),
|
||||||
convertBatch: jest.fn()
|
convertBatch: jest.fn(),
|
||||||
},
|
},
|
||||||
headerFooter: {
|
headerFooter: {
|
||||||
getSettings: jest.fn(),
|
getSettings: jest.fn(),
|
||||||
saveSettings: jest.fn(),
|
saveSettings: jest.fn(),
|
||||||
browseLogo: jest.fn(),
|
browseLogo: jest.fn(),
|
||||||
saveLogo: jest.fn(),
|
saveLogo: jest.fn(),
|
||||||
clearLogo: jest.fn()
|
clearLogo: jest.fn(),
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
getSettings: jest.fn(),
|
getSettings: jest.fn(),
|
||||||
updateSettings: jest.fn(),
|
updateSettings: jest.fn(),
|
||||||
setCustomStartPage: jest.fn()
|
setCustomStartPage: jest.fn(),
|
||||||
},
|
},
|
||||||
pdf: {
|
pdf: {
|
||||||
processOperation: jest.fn(),
|
processOperation: jest.fn(),
|
||||||
getPageCount: jest.fn(),
|
getPageCount: jest.fn(),
|
||||||
selectFolder: jest.fn()
|
selectFolder: jest.fn(),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock marked library
|
// Mock marked library
|
||||||
global.window.marked = {
|
global.window.marked = {
|
||||||
parse: jest.fn((text) => `<p>${text}</p>`),
|
parse: jest.fn((text) => `<p>${text}</p>`),
|
||||||
use: jest.fn()
|
use: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock DOMPurify
|
// Mock DOMPurify
|
||||||
global.window.DOMPurify = {
|
global.window.DOMPurify = {
|
||||||
sanitize: jest.fn((html) => html)
|
sanitize: jest.fn((html) => html),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock highlight.js
|
// Mock highlight.js
|
||||||
global.window.hljs = {
|
global.window.hljs = {
|
||||||
highlight: jest.fn((code, options) => ({ value: code })),
|
highlight: jest.fn((code, _options) => ({ value: code })),
|
||||||
highlightAuto: jest.fn((code) => ({ value: code })),
|
highlightAuto: jest.fn((code) => ({ value: code })),
|
||||||
getLanguage: jest.fn(() => true)
|
getLanguage: jest.fn(() => true),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock localStorage
|
||||||
@@ -79,7 +87,7 @@ const localStorageMock = {
|
|||||||
getItem: jest.fn(),
|
getItem: jest.fn(),
|
||||||
setItem: jest.fn(),
|
setItem: jest.fn(),
|
||||||
removeItem: jest.fn(),
|
removeItem: jest.fn(),
|
||||||
clear: jest.fn()
|
clear: jest.fn(),
|
||||||
};
|
};
|
||||||
global.localStorage = localStorageMock;
|
global.localStorage = localStorageMock;
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -30,13 +30,18 @@ describe('SidebarManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('starts collapsed', () => {
|
test('starts collapsed', () => {
|
||||||
const mgr = new SidebarManager();
|
new SidebarManager();
|
||||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expands on panel toggle', () => {
|
test('expands on panel toggle', () => {
|
||||||
const mgr = new SidebarManager();
|
const mgr = new SidebarManager();
|
||||||
mgr.registerPanel('test1', { title: 'Test 1', render: (c) => { c.innerHTML = 'hello'; } });
|
mgr.registerPanel('test1', {
|
||||||
|
title: 'Test 1',
|
||||||
|
render: (c) => {
|
||||||
|
c.innerHTML = 'hello';
|
||||||
|
},
|
||||||
|
});
|
||||||
mgr.togglePanel('test1');
|
mgr.togglePanel('test1');
|
||||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
|
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(false);
|
||||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
|
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Test 1');
|
||||||
@@ -52,8 +57,18 @@ describe('SidebarManager', () => {
|
|||||||
|
|
||||||
test('switches panels', () => {
|
test('switches panels', () => {
|
||||||
const mgr = new SidebarManager();
|
const mgr = new SidebarManager();
|
||||||
mgr.registerPanel('test1', { title: 'Panel 1', render: (c) => { c.innerHTML = 'one'; } });
|
mgr.registerPanel('test1', {
|
||||||
mgr.registerPanel('test2', { title: 'Panel 2', render: (c) => { c.innerHTML = 'two'; } });
|
title: 'Panel 1',
|
||||||
|
render: (c) => {
|
||||||
|
c.innerHTML = 'one';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mgr.registerPanel('test2', {
|
||||||
|
title: 'Panel 2',
|
||||||
|
render: (c) => {
|
||||||
|
c.innerHTML = 'two';
|
||||||
|
},
|
||||||
|
});
|
||||||
mgr.togglePanel('test1');
|
mgr.togglePanel('test1');
|
||||||
mgr.togglePanel('test2');
|
mgr.togglePanel('test2');
|
||||||
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
|
expect(document.querySelector('.sidebar-panel-title').textContent).toBe('Panel 2');
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
|
||||||
|
|
||||||
|
describe('SnapshotManager', () => {
|
||||||
|
let manager;
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {};
|
||||||
|
manager = new SnapshotManager({
|
||||||
|
get: (key) => store[key],
|
||||||
|
set: (key, value) => {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create stores snapshot with timestamp, content, wordCount', () => {
|
||||||
|
const snap = manager.create('Hello world this is a test', 'auto');
|
||||||
|
expect(snap).toHaveProperty('id');
|
||||||
|
expect(snap.content).toBe('Hello world this is a test');
|
||||||
|
expect(snap.wordCount).toBe(6);
|
||||||
|
expect(snap.label).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list returns snapshots ordered newest first', () => {
|
||||||
|
manager.create('first', 'auto');
|
||||||
|
manager.create('second', 'auto');
|
||||||
|
const list = manager.list();
|
||||||
|
expect(list.length).toBe(2);
|
||||||
|
expect(list[0].content).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getById returns specific snapshot', () => {
|
||||||
|
const snap = manager.create('find me', 'manual');
|
||||||
|
const found = manager.getById(snap.id);
|
||||||
|
expect(found.content).toBe('find me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getById returns null for missing id', () => {
|
||||||
|
expect(manager.getById('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore returns content of snapshot', () => {
|
||||||
|
const snap = manager.create('restore this', 'manual');
|
||||||
|
expect(manager.restore(snap.id)).toBe('restore this');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore throws for missing snapshot', () => {
|
||||||
|
expect(() => manager.restore('nope')).toThrow('Snapshot not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes a snapshot', () => {
|
||||||
|
const snap = manager.create('delete me', 'auto');
|
||||||
|
manager.delete(snap.id);
|
||||||
|
expect(manager.getById(snap.id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diff returns added/removed line counts', () => {
|
||||||
|
const snap = manager.create('line one\nline two\nline three', 'auto');
|
||||||
|
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
|
||||||
|
expect(result.added).toBe(2);
|
||||||
|
expect(result.removed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diff throws for missing snapshot', () => {
|
||||||
|
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prune keeps only the N most recent snapshots', () => {
|
||||||
|
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto');
|
||||||
|
manager.prune(5);
|
||||||
|
expect(manager.list().length).toBe(5);
|
||||||
|
expect(manager.list()[0].content).toBe('snap 9');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user