Compare commits

..
49 Commits
Author SHA1 Message Date
amitwh 3f0bf911a0 chore(repo-map): add auto-generated structural map
Generated with ~/.claude-shared/scripts/repo-map.sh (universal-ctags).
Signatures-only map of classes/functions/methods/interfaces/enums.

Amit Haridas
2026-07-18 07:23:25 +05:30
amitwh 2cac075c0e fix(word): harden DOCX preprocessing and temp-file cleanup
- Make the HTML preprocessor code-block and inline-code aware so code

  examples containing <style> / <div> / comments are preserved.

- Strip all raw <div> tags (not just alignment attributes) to avoid

  malformed output from unmatched closing tags.

- Handle uppercase tags and single/unquoted attributes.

- Create temporary DOCX input files inside private mkdtemp directories

  instead of predictable names in the shared temp directory.

- Wrap batch DOCX preprocessing in try/catch so one unreadable file

  does not abort the entire batch.

- Add regression tests for the edge cases above.
2026-06-30 19:57:40 +05:30
amitwh 94906a068a chore(release): bump version to 4.4.5 2026-06-30 13:57:34 +05:30
amitwh e72b863362 fix(word): strip HTML style blocks and alignment divs from DOCX input
Pre-process markdown before Word/DOCX export to remove raw HTML artifacts

(<style> blocks, HTML comments, and <div align=...> tags) that were

visible in the generated document. Applies to single and batch DOCX exports

via both Pandoc and WordTemplateExporter paths.
2026-06-30 13:57:29 +05:30
amitwh d705cfc30b fix(batch): resolve pandoc path handling and include-subfolders option
- Normalize pandoc command parsing with path.basename() to support bundled binary paths
- Use bundled pandoc binary in convertWithPandoc instead of relying on PATH
- Forward includeSubfolders checkbox state from renderer to main process
- Add pandoc availability check before batch conversion
- Re-enable Start button when batch conversion completes
- Clean up obsolete dist build artifact causing test snapshot warning
- Bump version to 4.4.4
2026-06-30 12:56:33 +05:30
amitwh 02e307f758 docs(claude-md): add tailored CLAUDE.md for master branch
Documents project architecture, Pandoc dependency resolution, build
pipeline (electron-builder, no bundler), security model notes
(contextIsolation: false on this branch), and development commands
extracted from actual package.json and source.

Amit Haridas
2026-06-19 23:18:00 +05:30
amitwh 5ad1d1d4b3 chore(release): bump version to 4.4.3 2026-06-11 21:17:37 +05:30
amitwh f480449301 fix(renderer): resolve syntax errors and undefined electronAPI on startup 2026-06-11 21:15:25 +05:30
amitwh 96df5652d6 fix: resolve list-directory IPC handler closing brace syntax error 2026-05-26 11:39:57 +05:30
amitwh a9e05d2c0f feat: implement Custom Preview CSS, Reveal.js options, Large File Mode, and Interactive PDF Thumbnail Sidebar 2026-05-25 23:11:47 +05:30
amitwh c982b3e90f chore(diagnostics): add logging to _renderPreview to trace markdown rendering
This will help identify whether the issue is:
1. marked.parse returning a Promise instead of string
2. DOMPurify.sanitize failing
3. The preview element not existing
4. Libraries not being loaded

Amit Haridas
2026-05-25 00:32:27 +05:30
amitwh cfaafc07b2 chore(deps): update vulnerable packages to patched versions
Updated packages:
- simple-git 3.32.3 → 3.36.0 (RCE vulnerability)
- fast-uri 3.1.0 → 3.1.2 (host confusion, path traversal)
- dompurify 3.3.1 → 3.4.5 (XSS bypasses)
- mermaid 11.13.0 → 11.15.0 (CSS injection, DoS)
- uuid 11.1.0 → 11.1.1 (buffer bounds check)
- ws 8.20.0 → 8.20.0 (uninitialized memory)
- ip-address 10.1.0 → 10.2.0 (XSS)
- @xmldom/xmldom 0.8.11 → 0.9.10 (XML injection, DoS)
- docx4js 3.3.0 → 2.0.1 (breaks xml2js dep chain)
- brace-expansion (transitive update)

Also applied lint:fix autofix (const correctness).

Amit Haridas
2026-05-25 00:00:28 +05:30
amitwh 94ad99dc4d fix(renderer): ensure tab content visibility after file open
- Add missing updateUI() call at end of openFile() to set .active class
  on tab content. Without this, the CSS rule .tab-content:not(.active)
  { display: none } kept newly opened files invisible.
- Add diagnostic logging to file-opened IPC handler and openFile()
  to trace future file loading issues.
- Add diagnostic logging to openFileFromPath() in main process.

Amit Haridas
2026-05-24 23:54:31 +05:30
amitwh baf644d62b fix(modal): prevent duplicate ModalManager declaration
window.ModalManager was set unconditionally, causing "Identifier
'ModalManager' has already been declared" when script tag in HTML
also loaded ModalManager before renderer.js required it.

Now checks !window.ModalManager before setting.

Amit Haridas
2026-05-22 22:08:06 +05:30
amitwh f9a5420ad2 4.4.1: update version everywhere, fix DOMPurify initialization
- Bump version to 4.4.1
- DOMPurify now initialized with window context (fixes markdown rendering)
- Add 'it' to eslint globals

Amit Haridas
2026-05-22 21:54:05 +05:30
amitwh f8361174f2 chore: add it to eslint globals, add debug logging to createEditor
Amit Haridas
2026-05-22 21:42:34 +05:30
amitwh 64df0660c8 fix(renderer): initialize DOMPurify with window context
require('dompurify') returns a factory function, not a sanitizer
instance. Calling .sanitize() on the factory threw a TypeError,
which was caught by the preview renderer's try-catch and displayed
a generic "Error rendering preview" message. Fix by invoking the
factory with the renderer's window object.

Amit Haridas
2026-05-03 16:38:47 +05:30
amitwhandCopilot bbfa2a38e9 security: add permission request handler for security isolation
Restrict Electron permission requests to only clipboard operations.
Deny: camera, microphone, geolocation, notifications, and all other
permissions by default.

Implements setPermissionRequestHandler on web-contents-created event
to enforce security policy early in the app lifecycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 17:19:52 +05:30
amitwhandCopilot 94ec3f45ce fix: show dynamic app version everywhere in UI
- Add get-app-version IPC handler in main.js (returns app.getVersion())
- Expose electronAPI.getAppVersion() in preload.js
- index.html: replace hardcoded v4.2.0 span with dynamic population
  from getAppVersion() in DOMContentLoaded
- welcome.js: accept appVersion param instead of hardcoded 4.1.0
- renderer.js: pass live version to createWelcomeContent()
- main.js about screen: use app.getVersion() instead of hardcoded 4.1.0
- Update stale @version 4.1.0 JSDoc comments to 4.3.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:31:18 +05:30
amitwhandCopilot a6747b12f0 fix: pass --publish=never to electron-builder in CI
electron-builder detects git tags in CI and tries to auto-publish to
GitHub, failing with 'GH_TOKEN not set'. We handle the release
separately via softprops/action-gh-release, so suppress auto-publish.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:16:20 +05:30
amitwhandCopilot 7a641b2618 fix: use deb+AppImage only in CI, release job continues if build fails
- Add build:linux-ci script (deb + AppImage, no snap — snapcraft not
  available on ubuntu-latest runners without extra setup)
- Switch release.yml linux build to npm run build:linux-ci
- release job: if: always() so Windows artifacts still get released
  even if linux build fails
- Download artifact steps: continue-on-error so missing platform
  doesn't block GitHub Release creation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:07:09 +05:30
amitwhandCopilot edefcb8409 fix: drop rpm build target and stale system tool depends
- Remove rpm from linux build targets (rpmbuild not available locally)
  CI can add it back with apt-get if needed, but pandoc/ffmpeg are now
  bundled so the rpm depends were incorrect anyway
- Remove rpmbuild apt install step from release.yml (not needed)
- Remove pandoc/ffmpeg from deb depends — they are now bundled binaries
- Keep imagemagick and libreoffice-common in deb depends (not bundled)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 23:00:00 +05:30
amitwhandCopilot 5ee986fab8 fix: bundle pandoc+ffmpeg, fix CI pipeline and Windows GitHub build
- Remove package-lock.json from .gitignore so npm ci works in CI
- Refactor main.js: delegate PDF ops to src/main/PDFOperations.js,
  git ops to src/main/GitOperations.js
- getPandocPath(): use bundled binary from resources/bin/ when packaged,
  fall back to dev bin/ or system pandoc in development
- getFFmpegPath(): use ffmpeg-static (asarUnpack) when packaged
- Install ffmpeg-static (v5.3.0, bundled 76MB binary)
- Add scripts/download-tools.js to fetch pandoc binary at build time
  (idempotent, runs on CI before electron-builder)
- electron-builder: add asarUnpack for ffmpeg-static, extraFiles for
  pandoc binary per platform (linux + win32)
- release.yml: switch build-windows to windows-latest runner with native
  NSIS support; add cert decode step; add download-tools step for both
  linux and windows jobs
- Fix lint error: hoist outlinePanelContainer to module scope so
  TabManager methods can reference it without no-undef errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:56:41 +05:30
amitwh c1573dba08 feat(writing-studio): add four sidebar panels (goals, snapshots, manuscript, proofread)
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 9576abc979 feat(writing-studio): add plugin manifest and entry point
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh caa3f3d35a feat(writing-studio): add project manager with compile and stats
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 2a6f0fc302 feat(writing-studio): add snapshot manager with diff and prune
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 4da5b7b9c4 feat(writing-studio): add goal tracker with streaks and history
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 1ba42592d7 feat(writing-studio): add sprint engine with WPM tracking
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh bc6f1a7d41 docs(writing-studio): add implementation plan for Writing Studio plugin
12 tasks across 9 chunks: SprintEngine, GoalTracker, SnapshotManager,
ProjectManager, manifest+entry point, 4 sidebar panels, CSS, timer UI.

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 539502d7ff feat(plugins): wire plugin system into app initialization
- Add plugin system bootstrap in renderer.js after sidebar/commands init
- Wire status bar DOM insertion, editor API, and IPC adapters
- Add plugin-settings:get/set IPC channels to preload allowlist
- Add IPC handlers in main process using existing settings store
- Fix eqeqeq warning in EventBus.hasHandler

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh b5771dd914 feat(plugins): add sample plugin demonstrating the system
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh a816b6ec32 feat(plugins): add PluginContext, PluginRegistry, and SettingsStore
- PluginContext: scoped API with crash-safe command wrappers
- PluginRegistry: lifecycle management with graceful init failure
- SettingsStore: plugin-scoped key/value via IPC backend
- Export hooks: pre/post hooks on registry for cross-plugin integration

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 54c9484cb5 feat(plugins): add PluginLoader with manifest discovery and validation
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 36e26318cd feat(plugins): add PluginAPI base class with no-op lifecycle
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 8abd580295 feat(plugins): add EventBus with typed events and crash-safe handlers
Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 80294c9876 docs: add plugin system implementation plan
9 tasks across 8 chunks, strict TDD:
- EventBus with crash-safe handlers
- PluginAPI base class
- PluginLoader with manifest validation
- PluginContext with scoped API
- PluginRegistry with lifecycle management
- SettingsStore for plugin-scoped settings
- Export hooks integration
- Sample plugin + renderer wiring

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh 148b549c83 docs: harden v5.0 spec based on review
- GGUF GPU: child process isolation with crash detection/restart
- Event bus: versioned payload schemas for all events
- Plugin sandbox: 5s handler timeout, IPC delegation for heavy ops
- AI streaming: full lifecycle with requestId, cancel, heartbeat, orphan cleanup
- Comment anchors: context-based positioning (not byte offsets) with re-anchor on file change
- Cross-plugin: capability discovery, 30s timeout, graceful degradation
- Bundle size: GPU variants as lazy downloads, not bundled by default
- Command uniqueness: registry rejects duplicates at load time

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwh a3b4065984 docs: add v5.0 platform design spec
Plugin-first architecture with four subsystems:
- Plugin system (registry, context API, event bus)
- Writing Studio (manuscript manager, sprints, snapshots, proofreading)
- AI Assistant (multi-provider: Ollama, LMStudio, GGUF+GPU, cloud APIs)
- Collaboration (git-based async, comments, review requests)

Amit Haridas
2026-04-23 22:55:12 +05:30
amitwhandCopilot 620227307d release: v4.3.0 - fix Windows SmartScreen blocking, add signing support
- Remove signAndEditExecutable:false so code signing works properly
- Add legalTrademarks and copyright metadata to build config
- Add publisherName via build.copyright (embedded in PE resources)
- Create scripts/create-selfsigned-cert.ps1 for local dev signing
- Update release.yml: build on windows-latest runner (not Wine),
  auto-sign when CSC_LINK_BASE64/CSC_KEY_PASSWORD secrets present,
  fall back to unsigned otherwise
- Add lint step to ci.yml (Phase 4.3 plan gap)
- Add .vscode/launch.json debug configs (Phase 4.3 plan gap)
- Fix .gitignore: exclude *.pfx/*.p12 cert files, track launch.json,
  fix concatenated agents.md/coverage/ lines

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-19 18:20:19 +05:30
amitwh 4426b75c6f fix: improve tab safety and app feedback 2026-04-14 22:30:46 +05:30
amitwh b7e12f7010 fix(deps): resolve all dependabot vulnerabilities
- Upgrade electron 37 -> 41
- Override lodash-es to patched version
- Zero vulnerabilities remaining

Amit Haridas
2026-04-06 20:51:41 +05:30
amitwh f0ab54cd60 chore: bump to v4.2.0 — Writer's Studio Feature Pack
Amit Haridas
2026-04-06 11:44:29 +05:30
amitwh 72a7a854d0 feat(analytics): add writing analytics with readability scores and vocabulary analysis
Amit Haridas
2026-04-06 11:42:22 +05:30
amitwh 50d0638c5c feat(zen): add distraction-free writing mode with typewriter scrolling
Zen mode hides all chrome and centers the editor with typewriter
scrolling, line dimming, and a floating word count HUD.
Toggle with F11, exit with Escape.

Amit Haridas
2026-04-06 11:37:51 +05:30
amitwh 7a3493e54f fix(outline): add tab-switch refresh and dark mode styles
Amit Haridas
2026-04-06 11:05:54 +05:30
amitwh b1a5784bcc feat(outline): add document outline sidebar panel with heading navigation
Amit Haridas
2026-04-06 10:40:29 +05:30
amitwh 24cb99658e docs: add Writer's Studio implementation plan
Detailed step-by-step plan for Zen Mode, Document Outline,
and Writing Analytics with exact file paths and code.

Amit Haridas
2026-04-06 07:47:35 +05:30
amitwh c042cf4580 docs: add Writer's Studio feature pack design
Design for three cohesive features: Zen Mode, Document Outline,
and Writing Analytics. Approved for v4.2.0.

Amit Haridas
2026-04-06 07:36:18 +05:30
108 changed files with 39451 additions and 11773 deletions
+3
View File
@@ -23,3 +23,6 @@ jobs:
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
+37 -14
View File
@@ -19,17 +19,17 @@ jobs:
node-version: 20
cache: npm
- name: Install rpmbuild
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Install dependencies
run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Run tests
run: npm test
- name: Build Linux packages
run: npm run build:linux
run: npm run build:linux-ci -- --publish=never
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
@@ -43,7 +43,7 @@ jobs:
retention-days: 5
build-windows:
runs-on: ubuntu-latest
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
@@ -53,17 +53,37 @@ jobs:
node-version: 20
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
run: npm ci
- name: Build Windows packages
run: npm run build:win
- name: Download external tools (pandoc)
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
uses: actions/upload-artifact@v4
@@ -71,23 +91,26 @@ jobs:
name: windows-artifacts
path: |
dist/*.exe
dist/*-win.zip
dist/*.zip
retention-days: 5
release:
needs: [build-linux, build-windows]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download Linux artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: linux-artifacts
path: dist
- name: Download Windows artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: windows-artifacts
path: dist
+15 -3
View File
@@ -8,14 +8,22 @@ Thumbs.db
*.swp
*.swo
*~
.vscode/
.vscode/*
!.vscode/launch.json
.idea/
*.iml
out/
.cache/
.npm/
.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
*.png.bak
@@ -33,4 +41,8 @@ pdf\ modal.png
# Claude/AI development files
.claude/
CLAUDE.md
agents.mdcoverage/
agents.md
coverage/
# Superpowers brainstorm artifacts
.superpowers/
+48
View File
@@ -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"
}
}
]
}
+25
View File
@@ -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.
+103
View File
@@ -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.
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version
v3.0.0
v4.4.5
+1 -1
View File
@@ -1,6 +1,6 @@
# MarkdownConverter - STRIDE Threat Model Analysis
**Version:** 4.0.0
**Version:** 4.1.0
**Date:** 2026-03-15
**Methodology:** STRIDE + MITRE ATT&CK Mapping
**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
+1
View File
@@ -75,6 +75,7 @@ module.exports = [
jest: 'readonly',
describe: 'readonly',
test: 'readonly',
it: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
+13547
View File
File diff suppressed because it is too large Load Diff
+33 -19
View File
@@ -1,6 +1,6 @@
{
"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",
"main": "src/main.js",
"scripts": {
@@ -14,13 +14,16 @@
"format:check": "prettier --check src tests",
"build": "electron-builder",
"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",
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
"build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux",
"build:linux-ci": "electron-builder --linux deb AppImage",
"build:local": "electron-builder --linux --win",
"dist": "electron-builder --publish=never",
"dist:all": "electron-builder -mwl",
"download-tools": "node scripts/download-tools.js",
"generate-icons": "node scripts/generate-icons.js"
},
"keywords": [
@@ -42,7 +45,7 @@
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"cross-env": "^10.0.0",
"electron": "^37.4.0",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -70,9 +73,10 @@
"codemirror": "^6.0.2",
"core-util-is": "^1.0.3",
"docx": "^9.6.0",
"docx4js": "^3.3.0",
"docx4js": "^2.0.1",
"dompurify": "^3.3.1",
"electron-store": "^10.1.0",
"ffmpeg-static": "^5.3.0",
"highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0",
"marked": "^17.0.3",
@@ -89,11 +93,14 @@
"overrides": {
"jszip": "^3.10.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": {
"appId": "com.concreteinfo.markdownconverter",
"productName": "MarkdownConverter",
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
"directories": {
"output": "dist"
},
@@ -105,6 +112,10 @@
"node_modules/**/*",
"package.json"
],
"asarUnpack": [
"node_modules/ffmpeg-static/**"
],
"extraFiles": [],
"fileAssociations": [
{
"ext": "md",
@@ -155,7 +166,15 @@
],
"artifactName": "${productName}-${version}-${arch}.${ext}",
"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": {
"oneClick": false,
@@ -177,29 +196,24 @@
"target": [
"deb",
"AppImage",
"snap",
"rpm"
"snap"
],
"category": "Utility",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
"extraFiles": [
{
"from": "bin/linux/pandoc",
"to": "bin/pandoc"
}
]
},
"deb": {
"depends": [
"pandoc",
"ffmpeg",
"imagemagick",
"libreoffice-common"
],
"description": "Professional Markdown editor and universal file converter",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
},
"rpm": {
"depends": [
"pandoc",
"ffmpeg",
"ImageMagick",
"libreoffice-core"
]
}
}
}
+604
View File
@@ -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
```
+59
View File
@@ -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
}
+148
View File
@@ -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);
});
+90 -86
View File
@@ -4,7 +4,7 @@
* Implements file system operations for Electron using IPC.
* This abstracts file operations to enable easier testing and migration.
*
* @version 4.1.0
* @version 4.4.1
*/
/**
@@ -12,96 +12,100 @@
* @type {import('../types').FileSystemAdapter}
*/
const electronFsAdapter = {
/**
* Read file content
* @param {string} path - File path
* @returns {Promise<string>} File content
*/
async readFile(path) {
return await window.electronAPI.readFile(path);
},
/**
* Read file content
* @param {string} path - File path
* @returns {Promise<string>} File content
*/
async readFile(path) {
return await window.electronAPI.file.read(path);
},
/**
* Write content to file
* @param {string} path - File path
* @param {string} content - File content
* @returns {Promise<void>}
*/
async writeFile(path, content) {
return await window.electronAPI.writeFile(path, content);
},
/**
* Write content to file
* @param {string} path - File path
* @param {string} content - File content
* @returns {Promise<void>}
*/
async writeFile(path, content) {
return await window.electronAPI.file.write(path, content);
},
/**
* Delete file
* @param {string} path - File path
* @returns {Promise<void>}
*/
async deleteFile(path) {
// TODO: Add IPC channel for delete
throw new Error('deleteFile not implemented');
},
/**
* Delete file
* @param {string} path - File path
* @returns {Promise<void>}
*/
async deleteFile(path) {
return await window.electronAPI.file.delete(path);
},
/**
* Ensure directory exists
* @param {string} path - Directory path
* @returns {Promise<void>}
*/
async ensureDir(path) {
// TODO: Add IPC channel for ensureDir
throw new Error('ensureDir not implemented');
},
/**
* Ensure directory exists
* @param {string} path - Directory path
* @returns {Promise<void>}
*/
async ensureDir(path) {
return await window.electronAPI.file.ensureDir(path);
},
/**
* List directory contents
* @param {string} path - Directory path
* @returns {Promise<Array<import('../types').FileInfo>>}
*/
async listDirectory(path) {
// TODO: Add IPC channel for listDirectory
throw new Error('listDirectory not implemented');
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
// TODO: Add IPC channel for exists
throw new Error('exists not implemented');
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
// TODO: Add IPC channel for isDirectory
throw new Error('isDirectory not implemented');
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
// TODO: Add IPC channel for copy
throw new Error('copy not implemented');
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
// TODO: Add IPC channel for move
throw new Error('move not implemented');
/**
* List directory contents
* @param {string} path - Directory path
* @returns {Promise<Array<import('../types').FileInfo>>}
*/
async listDirectory(path) {
const result = await window.electronAPI.invoke('list-directory', path);
if (!result?.entries) {
return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path,
}));
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
return await window.electronAPI.file.exists(path);
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
return await window.electronAPI.file.isDirectory(path);
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
return await window.electronAPI.file.copy(source, dest);
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
},
};
module.exports = { electronFsAdapter };
+2 -2
View File
@@ -5,7 +5,7 @@
* Adapters abstract file system, conversion, and system operations
* to enable easier testing and future platform migration.
*
* @version 4.1.0
* @version 4.4.1
*/
/**
@@ -129,5 +129,5 @@
*/
module.exports = {
// Type definitions are JSDoc only, no runtime exports needed
// Type definitions are JSDoc only, no runtime exports needed
};
+122
View File
@@ -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">&times;</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} &bull; 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 };
+200
View File
@@ -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 };
+675 -526
View File
File diff suppressed because it is too large Load Diff
+91 -87
View File
@@ -1,109 +1,113 @@
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
close() {
this.overlay.classList.add('hidden');
}
close() {
this.overlay.classList.add('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
renderResults(query) {
this.filteredCommands = query
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
renderResults(query) {
this.filteredCommands = query
? this.commands.filter((cmd) => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
this.results.innerHTML = this.filteredCommands
.map(
(cmd, i) => `
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
</div>
`).join('');
`
)
.join('');
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
}
}
module.exports = { CommandPalette };
+40 -28
View File
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python;
const {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} = require('@codemirror/commands');
const {
searchKeymap,
highlightSelectionMatches,
} = require('@codemirror/search');
const {
autocompletion,
completionKeymap,
} = require('@codemirror/autocomplete');
const {
bracketMatching,
foldGutter,
indentOnInput,
} = require('@codemirror/language');
const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark');
// Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({
'&': {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace"
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
},
'.cm-content': {
fontFamily: 'inherit'
fontFamily: 'inherit',
},
'.cm-scroller': {
fontFamily: 'inherit'
}
fontFamily: 'inherit',
},
});
/**
@@ -59,6 +44,18 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view
*/
function createEditor(parentElement, options = {}) {
console.log(
'[createEditor] Called with parentElement:',
parentElement?.id,
'dimensions:',
parentElement?.clientWidth,
'x',
parentElement?.clientHeight
);
if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null;
}
const {
content = '',
onChange = () => {},
@@ -120,11 +117,26 @@ function createEditor(parentElement, options = {}) {
*/
function getLanguageExtension(lang) {
const loaders = {
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); },
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); },
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); },
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); },
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); },
javascript: () => {
if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
return _javascript();
},
html: () => {
if (!_html) _html = require('@codemirror/lang-html').html;
return _html();
},
css: () => {
if (!_css) _css = require('@codemirror/lang-css').css;
return _css();
},
json: () => {
if (!_json) _json = require('@codemirror/lang-json').json;
return _json();
},
python: () => {
if (!_python) _python = require('@codemirror/lang-python').python;
return _python();
},
markdown: () => markdown({ base: markdownLanguage }),
};
loaders.js = loaders.javascript;
+45 -45
View File
@@ -2,74 +2,74 @@
/* Inter Font Family */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
+2383 -1525
View File
File diff suppressed because it is too large Load Diff
+1892 -1595
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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 };
+452
View File
@@ -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,
};
+12
View File
@@ -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 };
+43
View File
@@ -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 };
+23
View File
@@ -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 };
+75
View File
@@ -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 };
+78
View File
@@ -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 };
+80
View File
@@ -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 };
+23
View File
@@ -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
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access
*
* @version 4.0.0
* @version 4.4.1
*/
const { contextBridge, ipcRenderer } = require('electron');
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
'save-recent-files',
'clear-recent-files',
'renderer-ready',
'select-custom-css',
// Theme
'get-theme',
@@ -101,6 +102,14 @@ const ALLOWED_SEND_CHANNELS = [
// File Explorer
'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
// Git
'git-status',
@@ -127,7 +136,11 @@ const ALLOWED_SEND_CHANNELS = [
'export',
// Git diff
'git-diff'
'git-diff',
// Plugin settings
'plugin-settings:get',
'plugin-settings:set',
];
const ALLOWED_RECEIVE_CHANNELS = [
@@ -138,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
'get-content-for-save',
'get-content-for-spreadsheet',
'recent-files-cleared',
'load-custom-css',
'clear-custom-css',
// UI toggles
'toggle-preview',
@@ -222,7 +237,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
/**
@@ -321,23 +336,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
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: {
get: () => ipcRenderer.send('get-theme')
get: () => ipcRenderer.send('get-theme'),
},
// Print Operations
print: {
doPrint: (options) => ipcRenderer.send('do-print', options)
doPrint: (options) => ipcRenderer.send('do-print', options),
},
// Export Operations
export: {
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format })
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
},
// Batch Conversion
@@ -345,7 +368,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
convert: (inputFolder, outputFolder, format, options) => {
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
},
selectFolder: (type) => ipcRenderer.send('select-folder', type)
selectFolder: (type) => ipcRenderer.send('select-folder', type),
},
// Universal Converter
@@ -354,8 +377,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
},
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder });
}
ipcRenderer.send('universal-convert-batch', {
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
},
},
// Header/Footer Operations
@@ -363,22 +392,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.send('get-header-footer-settings'),
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position)
saveLogo: (position, filePath) =>
ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
},
// Page Settings
page: {
getSettings: () => ipcRenderer.send('get-page-settings'),
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber)
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
},
// PDF Operations
pdf: {
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId)
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
},
// Image Converter Operations
@@ -387,7 +417,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
resize: (data) => ipcRenderer.send('image-resize', data),
compress: (data) => ipcRenderer.send('image-compress', data),
rotate: (data) => ipcRenderer.send('image-rotate', data)
rotate: (data) => ipcRenderer.send('image-rotate', data),
},
// Audio Converter Operations
@@ -396,7 +426,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
extract: (data) => ipcRenderer.send('audio-extract', data),
trim: (data) => ipcRenderer.send('audio-trim', data),
merge: (data) => ipcRenderer.send('audio-merge', data)
merge: (data) => ipcRenderer.send('audio-merge', data),
},
// Video Converter Operations
@@ -406,14 +436,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
compress: (data) => ipcRenderer.send('video-compress', data),
trim: (data) => ipcRenderer.send('video-trim', data),
extractFrames: (data) => ipcRenderer.send('video-frames', data),
toGif: (data) => ipcRenderer.send('video-gif', data)
toGif: (data) => ipcRenderer.send('video-gif', data),
},
// Generator Windows
generators: {
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
+90 -90
View File
@@ -1,85 +1,85 @@
class PrintPreview {
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach((id) => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Note: Backdrop click and Escape key are now handled by ModalManager
}
// Note: Backdrop click and Escape key are now handled by ModalManager
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
this._lastContent = htmlContent;
this._lastContent = htmlContent;
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
// Get dimensions for paper size
const sizes = {
A3: { width: '297mm', height: '420mm' },
A4: { width: '210mm', height: '297mm' },
A5: { width: '148mm', height: '210mm' },
Letter: { width: '8.5in', height: '11in' },
Legal: { width: '8.5in', height: '14in' },
Tabloid: { width: '11in', height: '17in' },
};
// Get dimensions for paper size
const sizes = {
'A3': { width: '297mm', height: '420mm' },
'A4': { width: '210mm', height: '297mm' },
'A5': { width: '148mm', height: '210mm' },
'Letter': { width: '8.5in', height: '11in' },
'Legal': { width: '8.5in', height: '14in' },
'Tabloid': { width: '11in', height: '17in' },
};
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const previewHtml = `
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
@@ -105,34 +105,34 @@ class PrintPreview {
</html>
`;
frame.srcdoc = previewHtml;
}
frame.srcdoc = previewHtml;
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
}
module.exports = { PrintPreview };
+5249 -3771
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -1,49 +1,49 @@
class ReplPanel {
constructor() {
this.panel = document.getElementById('bottom-panel');
this.output = document.getElementById('repl-output');
this.setupEventListeners();
}
constructor() {
this.panel = document.getElementById('bottom-panel');
this.output = document.getElementById('repl-output');
this.setupEventListeners();
}
setupEventListeners() {
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
}
setupEventListeners() {
document.getElementById('bottom-panel-toggle')?.addEventListener('click', () => this.toggle());
document.getElementById('repl-clear')?.addEventListener('click', () => this.clear());
}
toggle() {
this.panel.classList.toggle('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
}
toggle() {
this.panel.classList.toggle('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = this.panel.classList.contains('collapsed') ? '\u25BC' : '\u25B2';
}
show() {
this.panel.classList.remove('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = '\u25B2';
}
show() {
this.panel.classList.remove('collapsed');
const btn = document.getElementById('bottom-panel-toggle');
if (btn) btn.textContent = '\u25B2';
}
clear() {
if (this.output) this.output.innerHTML = '';
}
clear() {
if (this.output) this.output.innerHTML = '';
}
appendOutput(command, result) {
const entry = document.createElement('div');
entry.className = 'repl-entry';
entry.innerHTML = `
appendOutput(command, result) {
const entry = document.createElement('div');
entry.className = 'repl-entry';
entry.innerHTML = `
<div class="repl-command">\u25B6 ${command}</div>
${result.stdout ? `<div class="repl-stdout">${this.escapeHtml(result.stdout)}</div>` : ''}
${result.stderr ? `<div class="repl-stderr">${this.escapeHtml(result.stderr)}</div>` : ''}
${result.error ? `<div class="repl-error">Error: ${this.escapeHtml(result.error)}</div>` : ''}
`;
this.output?.appendChild(entry);
this.output?.scrollTo(0, this.output.scrollHeight);
}
this.output?.appendChild(entry);
this.output?.scrollTo(0, this.output.scrollHeight);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
module.exports = { ReplPanel };
+65 -41
View File
@@ -1,7 +1,5 @@
const path = require('path');
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
container.innerHTML = `
container.innerHTML = `
<div class="explorer-panel">
<div class="explorer-toolbar">
<input type="text" class="explorer-path" id="explorer-path" value="${currentDir || ''}" placeholder="Open a folder..." readonly>
@@ -11,60 +9,86 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
</div>
`;
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
const dir = await listDirectory(null); // null means open folder dialog
if (dir) {
document.getElementById('explorer-path').value = dir.path;
renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, dir.path);
}
});
if (currentDir) {
listDirectory(currentDir).then(dir => {
if (dir) renderTree(document.getElementById('explorer-tree'), dir.entries, listDirectory, onFileOpen, currentDir);
});
document.getElementById('explorer-browse')?.addEventListener('click', async () => {
const dir = await listDirectory(null); // null means open folder dialog
if (dir) {
document.getElementById('explorer-path').value = dir.path;
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
dir.path
);
}
});
if (currentDir) {
listDirectory(currentDir).then((dir) => {
if (dir)
renderTree(
document.getElementById('explorer-tree'),
dir.entries,
listDirectory,
onFileOpen,
currentDir
);
});
}
}
function renderTree(container, entries, listDirectory, onFileOpen, basePath) {
container.innerHTML = entries.map(entry => {
if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
container.innerHTML = entries
.map((entry) => {
if (entry.isDirectory) {
return `<div class="tree-item tree-folder collapsed" data-path="${entry.path}">
<span class="tree-icon">&#x25B6;</span>
<span class="tree-name">${entry.name}</span>
<div class="tree-children"></div>
</div>`;
}
return `<div class="tree-item tree-file" data-path="${entry.path}">
}
return `<div class="tree-item tree-file" data-path="${entry.path}">
<span class="tree-icon">${getFileIcon(entry.name)}</span>
<span class="tree-name">${entry.name}</span>
</div>`;
}).join('');
})
.join('');
container.querySelectorAll('.tree-folder').forEach(el => {
el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) {
const dir = await listDirectory(el.dataset.path);
if (dir) {
const childContainer = el.querySelector('.tree-children');
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
}
}
el.classList.toggle('collapsed');
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed') ? '\u25B6' : '\u25BC';
});
container.querySelectorAll('.tree-folder').forEach((el) => {
el.querySelector('.tree-name').addEventListener('click', async () => {
const isCollapsed = el.classList.contains('collapsed');
if (isCollapsed) {
const dir = await listDirectory(el.dataset.path);
if (dir) {
const childContainer = el.querySelector('.tree-children');
renderTree(childContainer, dir.entries, listDirectory, onFileOpen, el.dataset.path);
}
}
el.classList.toggle('collapsed');
el.querySelector('.tree-icon').textContent = el.classList.contains('collapsed')
? '\u25B6'
: '\u25BC';
});
});
container.querySelectorAll('.tree-file').forEach(el => {
el.addEventListener('click', () => onFileOpen(el.dataset.path));
});
container.querySelectorAll('.tree-file').forEach((el) => {
el.addEventListener('click', () => onFileOpen(el.dataset.path));
});
}
function getFileIcon(filename) {
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}' };
return icons[ext] || '\u{1F4C4}';
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}',
};
return icons[ext] || '\u{1F4C4}';
}
module.exports = { renderExplorerPanel };
+55 -45
View File
@@ -1,5 +1,5 @@
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = `
function renderGitPanel(container, { gitStatus, _gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = `
<div class="git-panel">
<div class="git-section">
<h4 class="git-section-title">Changes</h4>
@@ -19,66 +19,76 @@ function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gi
</div>
`;
loadGitStatus();
loadGitStatus();
async function loadGitStatus() {
const status = await gitStatus();
const changesEl = document.getElementById('git-changes');
if (!status || !changesEl) return;
async function loadGitStatus() {
const status = await gitStatus();
const changesEl = document.getElementById('git-changes');
if (!status || !changesEl) return;
if (status.error) {
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
return;
}
if (status.error) {
changesEl.innerHTML = `<p class="git-info">${status.error}</p>`;
return;
}
const files = [
...status.modified.map(f => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map(f => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map(f => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map(f => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map(f => ({ file: f, status: 'S', color: '#3b82f6' })),
];
const files = [
...status.modified.map((f) => ({ file: f, status: 'M', color: '#f59e0b' })),
...status.not_added.map((f) => ({ file: f, status: '?', color: '#6b7280' })),
...status.created.map((f) => ({ file: f, status: 'A', color: '#10b981' })),
...status.deleted.map((f) => ({ file: f, status: 'D', color: '#ef4444' })),
...status.staged.map((f) => ({ file: f, status: 'S', color: '#3b82f6' })),
];
if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else {
changesEl.innerHTML = files.map(f => `
if (files.length === 0) {
changesEl.innerHTML = '<p class="git-info">No changes</p>';
} else {
changesEl.innerHTML = files
.map(
(f) => `
<div class="git-file" data-file="${f.file}">
<span class="git-file-status" style="color:${f.color}">${f.status}</span>
<span class="git-file-name">${f.file}</span>
<button class="git-stage-btn" data-file="${f.file}" title="Stage file">+</button>
</div>
`).join('');
`
)
.join('');
changesEl.querySelectorAll('.git-stage-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await gitStage([btn.dataset.file]);
loadGitStatus();
});
});
}
changesEl.querySelectorAll('.git-stage-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await gitStage([btn.dataset.file]);
loadGitStatus();
});
});
}
// Load log
const log = await gitLog();
const logEl = document.getElementById('git-log');
if (log && logEl) {
logEl.innerHTML = (log.all || []).slice(0, 10).map(entry => `
// Load log
const log = await gitLog();
const logEl = document.getElementById('git-log');
if (log && logEl) {
logEl.innerHTML =
(log.all || [])
.slice(0, 10)
.map(
(entry) => `
<div class="git-log-entry">
<div class="git-log-msg">${entry.message}</div>
<div class="git-log-meta">${entry.date?.substring(0, 10) || ''} &middot; ${entry.author_name || ''}</div>
</div>
`).join('') || '<p class="git-info">No commits</p>';
}
`
)
.join('') || '<p class="git-info">No commits</p>';
}
}
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return;
await gitCommit(msg);
document.getElementById('git-commit-msg').value = '';
loadGitStatus();
});
document.getElementById('git-commit-btn')?.addEventListener('click', async () => {
const msg = document.getElementById('git-commit-msg')?.value?.trim();
if (!msg) return;
await gitCommit(msg);
document.getElementById('git-commit-msg').value = '';
loadGitStatus();
});
}
module.exports = { renderGitPanel };
+120
View File
@@ -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 };
+42 -40
View File
@@ -1,50 +1,52 @@
class SidebarManager {
constructor() {
this.sidebar = document.getElementById('sidebar');
this.panelContent = document.getElementById('sidebar-panel-content');
this.panelTitle = document.querySelector('.sidebar-panel-title');
this.activePanel = null;
this.panels = new Map();
this.setupEventListeners();
}
constructor() {
this.sidebar = document.getElementById('sidebar');
this.panelContent = document.getElementById('sidebar-panel-content');
this.panelTitle = document.querySelector('.sidebar-panel-title');
this.activePanel = null;
this.panels = new Map();
this.setupEventListeners();
}
setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach(btn => {
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
});
document.querySelector('.sidebar-panel-close')?.addEventListener('click', () => this.collapse());
}
setupEventListeners() {
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.addEventListener('click', () => this.togglePanel(btn.dataset.panel));
});
document
.querySelector('.sidebar-panel-close')
?.addEventListener('click', () => this.collapse());
}
registerPanel(name, { title, render }) {
this.panels.set(name, { title, render });
}
registerPanel(name, { title, render }) {
this.panels.set(name, { title, render });
}
togglePanel(name) {
if (this.activePanel === name) {
this.collapse();
} else {
this.expand(name);
}
togglePanel(name) {
if (this.activePanel === name) {
this.collapse();
} else {
this.expand(name);
}
}
expand(name) {
const panel = this.panels.get(name);
if (!panel) return;
this.sidebar.classList.remove('collapsed');
this.panelTitle.textContent = panel.title;
this.panelContent.innerHTML = '';
panel.render(this.panelContent);
this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach(btn => {
btn.classList.toggle('active', btn.dataset.panel === name);
});
}
expand(name) {
const panel = this.panels.get(name);
if (!panel) return;
this.sidebar.classList.remove('collapsed');
this.panelTitle.textContent = panel.title;
this.panelContent.innerHTML = '';
panel.render(this.panelContent);
this.activePanel = name;
document.querySelectorAll('.sidebar-icon').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.panel === name);
});
}
collapse() {
this.sidebar.classList.add('collapsed');
this.activePanel = null;
document.querySelectorAll('.sidebar-icon').forEach(btn => btn.classList.remove('active'));
}
collapse() {
this.sidebar.classList.add('collapsed');
this.activePanel = null;
document.querySelectorAll('.sidebar-icon').forEach((btn) => btn.classList.remove('active'));
}
}
module.exports = { SidebarManager };
+52 -40
View File
@@ -1,5 +1,5 @@
function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippet, onInsert }) {
container.innerHTML = `
container.innerHTML = `
<div class="snippets-panel">
<div class="snippets-toolbar">
<input type="text" class="snippets-search" id="snippets-search" placeholder="Search snippets...">
@@ -9,22 +9,29 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
</div>
`;
let snippets = [];
let snippets = [];
async function loadSnippets() {
snippets = await getSnippets() || [];
renderList(document.getElementById('snippets-search')?.value || '');
}
async function loadSnippets() {
snippets = (await getSnippets()) || [];
renderList(document.getElementById('snippets-search')?.value || '');
}
function renderList(query) {
const list = document.getElementById('snippets-list');
if (!list) return;
function renderList(query) {
const list = document.getElementById('snippets-list');
if (!list) return;
const filtered = query
? snippets.filter(s => s.name.toLowerCase().includes(query.toLowerCase()) || (s.language || '').toLowerCase().includes(query.toLowerCase()))
: snippets;
const filtered = query
? snippets.filter(
(s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
(s.language || '').toLowerCase().includes(query.toLowerCase())
)
: 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-header">
<span class="snippet-name">${s.name}</span>
@@ -36,36 +43,41 @@ function renderSnippetsPanel(container, { getSnippets, saveSnippet, deleteSnippe
<button class="snippet-delete" data-id="${s.id}" title="Delete">&times;</button>
</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 => {
btn.addEventListener('click', () => {
const s = snippets.find(sn => sn.id === btn.dataset.id);
if (s) onInsert(s.code);
});
});
list.querySelectorAll('.snippet-delete').forEach(btn => {
btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id);
loadSnippets();
});
});
}
document.getElementById('snippets-search')?.addEventListener('input', (e) => renderList(e.target.value));
document.getElementById('snippets-add')?.addEventListener('click', () => {
const name = prompt('Snippet name:');
if (!name) return;
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
const code = prompt('Paste your code snippet:');
if (!code) return;
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
list.querySelectorAll('.snippet-insert').forEach((btn) => {
btn.addEventListener('click', () => {
const s = snippets.find((sn) => sn.id === btn.dataset.id);
if (s) onInsert(s.code);
});
});
loadSnippets();
list.querySelectorAll('.snippet-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteSnippet(btn.dataset.id);
loadSnippets();
});
});
}
document
.getElementById('snippets-search')
?.addEventListener('input', (e) => renderList(e.target.value));
document.getElementById('snippets-add')?.addEventListener('click', () => {
const name = prompt('Snippet name:');
if (!name) return;
const language = prompt('Language (e.g., javascript, python, html):') || 'text';
const code = prompt('Paste your code snippet:');
if (!code) return;
saveSnippet({ id: Date.now().toString(), name, language, code }).then(() => loadSnippets());
});
loadSnippets();
}
module.exports = { renderSnippetsPanel };
+24 -19
View File
@@ -1,34 +1,39 @@
const fs = require('fs');
const path = require('path');
const templates = [
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
{ name: 'Technical Spec', file: 'technical-spec.md', description: 'Requirements and architecture' },
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
{
name: 'Technical Spec',
file: 'technical-spec.md',
description: 'Requirements and architecture',
},
{ name: 'Changelog', file: 'changelog.md', description: 'Keep a Changelog format' },
{ name: 'README', file: 'readme.md', description: 'Project documentation' },
{ name: 'Project Plan', file: 'project-plan.md', description: 'Goals, milestones, timeline' },
{ name: 'API Docs', file: 'api-docs.md', description: 'API endpoint documentation' },
{ name: 'Tutorial', file: 'tutorial.md', description: 'Step-by-step guide' },
{ name: 'Release Notes', file: 'release-notes.md', description: 'Version release summary' },
{ name: 'Comparison', file: 'comparison.md', description: 'Feature comparison table' },
];
function renderTemplatesPanel(container, onSelect) {
container.innerHTML = `
container.innerHTML = `
<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-title">${t.name}</div>
<div class="panel-list-item-desc">${t.description}</div>
</div>
`).join('')}
`
)
.join('')}
</div>
`;
container.querySelectorAll('.template-item').forEach(el => {
el.addEventListener('click', () => onSelect(el.dataset.file));
});
container.querySelectorAll('.template-item').forEach((el) => {
el.addEventListener('click', () => onSelect(el.dataset.file));
});
}
module.exports = { renderTemplatesPanel, templates };
+22 -7
View File
@@ -1,7 +1,7 @@
/**
* ConcreteInfo Theme for MarkdownConverter
* 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
============================================ */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-fade-in {
+577 -166
View File
@@ -60,7 +60,15 @@
/* Base styles - Reset is in styles.css */
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;
height: 100vh;
/* Background controlled by themes - do not set here */
@@ -68,9 +76,15 @@ body {
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.container {
@@ -345,7 +359,9 @@ body {
}
/* 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 */
font-weight: 700;
margin-top: 28px;
@@ -695,11 +711,7 @@ body {
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
@@ -798,7 +810,9 @@ body {
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
transition:
width 0.6s,
height 0.6s;
}
.toolbar button:hover::before,
@@ -1046,7 +1060,7 @@ body.theme-paper .pdf-viewer-toolbar,
body.theme-rosepine-dawn .pdf-viewer-toolbar {
background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%) !important;
border-bottom: 1px solid #d0d0d0 !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
body.theme-light .pdf-viewer-toolbar button,
@@ -1061,7 +1075,7 @@ body.theme-rosepine-dawn .pdf-viewer-toolbar button {
background: #fff !important;
border: 1px solid #ccc !important;
color: #333 !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
body.theme-light .pdf-viewer-toolbar button:hover,
@@ -1444,7 +1458,7 @@ body.theme-rosepine-dawn .pdf-viewer {
.export-dialog,
.batch-dialog,
.editor-pane,
[id^="editor-pane-"],
[id^='editor-pane-'],
.new-tab-button,
.tab-close {
display: none !important;
@@ -1452,9 +1466,9 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Show preview content */
.preview-pane,
[id^="preview-pane-"],
[id^='preview-pane-'],
.preview-content,
[id^="preview-"] {
[id^='preview-'] {
display: block !important;
width: 100% !important;
padding: 20px !important;
@@ -1491,9 +1505,9 @@ body.theme-rosepine-dawn .pdf-viewer {
body.printing-no-styles .tab-content,
body.printing-no-styles .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 [id^="preview-"] {
body.printing-no-styles [id^='preview-'] {
background: white !important;
color: #000 !important;
}
@@ -1539,27 +1553,27 @@ body.theme-rosepine-dawn .pdf-viewer {
/* Common dark theme styles for 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 [id^="preview-"],
body.theme-one-dark [id^='preview-'],
body.theme-dracula .preview-content,
body.theme-dracula [id^="preview-"],
body.theme-dracula [id^='preview-'],
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 [id^="preview-"],
body.theme-tokyo-night [id^='preview-'],
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 [id^="preview-"],
body.theme-ayu-dark [id^='preview-'],
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 [id^="preview-"],
body.theme-oceanic-next [id^='preview-'],
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 [id^="preview-"] {
body.theme-cobalt2 [id^='preview-'] {
color: #e6e6e6;
background: transparent;
}
@@ -2145,204 +2159,601 @@ body.theme-rosepine-dawn .batch-dialog-footer {
Command Palette (Ctrl+Shift+P)
======================================== */
.command-palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
justify-content: center;
padding-top: 80px;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
justify-content: center;
padding-top: 80px;
}
.command-palette-overlay.hidden {
display: none;
display: none;
}
.command-palette {
width: 560px;
max-height: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
width: 560px;
max-height: 400px;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: flex;
flex-direction: column;
}
.command-palette-input {
width: 100%;
padding: 14px 18px;
border: none;
outline: none;
font-size: 15px;
border-bottom: 1px solid #e5e7eb;
font-family: inherit;
width: 100%;
padding: 14px 18px;
border: none;
outline: none;
font-size: 15px;
border-bottom: 1px solid #e5e7eb;
font-family: inherit;
}
.command-palette-results {
overflow-y: auto;
max-height: 340px;
overflow-y: auto;
max-height: 340px;
}
.command-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
cursor: pointer;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
cursor: pointer;
font-size: 14px;
}
.command-item:hover,
.command-item.selected {
background: #f3f4f6;
background: #f3f4f6;
}
.command-label strong {
color: #5661b3;
color: #5661b3;
}
.command-shortcut {
font-size: 12px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #9ca3af;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* Dark theme command palette */
body[class*="dark"] .command-palette {
background: #1e1e1e;
border: 1px solid #333;
body[class*='dark'] .command-palette {
background: #1e1e1e;
border: 1px solid #333;
}
body[class*="dark"] .command-palette-input {
background: #1e1e1e;
color: #eee;
border-color: #333;
body[class*='dark'] .command-palette-input {
background: #1e1e1e;
color: #eee;
border-color: #333;
}
body[class*="dark"] .command-item:hover,
body[class*="dark"] .command-item.selected {
background: #2d2d2d;
body[class*='dark'] .command-item:hover,
body[class*='dark'] .command-item.selected {
background: #2d2d2d;
}
body[class*="dark"] .command-shortcut {
background: #333;
color: #888;
body[class*='dark'] .command-shortcut {
background: #333;
color: #888;
}
/* ========================================
Breadcrumb Bar
======================================== */
.breadcrumb-bar {
padding: 4px 12px;
font-size: 12px;
color: var(--gray-500, #6b7280);
border-bottom: 1px solid var(--gray-200, #e5e7eb);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace;
padding: 4px 12px;
font-size: 12px;
color: var(--gray-500, #6b7280);
border-bottom: 1px solid var(--gray-200, #e5e7eb);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
background: var(--gray-50, #f9fafb);
font-family: 'JetBrains Mono', monospace;
}
body[class*="dark"] .breadcrumb-bar {
background: #1a1a1a;
color: #888;
border-color: #333;
body[class*='dark'] .breadcrumb-bar {
background: #1a1a1a;
color: #888;
border-color: #333;
}
/* Print Preview Dialog */
.print-preview-dialog {
width: 90vw;
max-width: 1200px;
height: 80vh;
display: flex;
flex-direction: column;
width: 90vw;
max-width: 1200px;
height: 80vh;
display: flex;
flex-direction: column;
}
.print-preview-body {
display: flex;
flex: 1;
overflow: hidden;
display: flex;
flex: 1;
overflow: hidden;
}
.print-preview-sidebar {
width: 260px;
padding: 16px;
border-right: 1px solid var(--gray-200, #e5e7eb);
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
width: 260px;
padding: 16px;
border-right: 1px solid var(--gray-200, #e5e7eb);
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.print-preview-content {
flex: 1;
padding: 16px;
background: #e5e7eb;
display: flex;
justify-content: center;
overflow: auto;
flex: 1;
padding: 16px;
background: #e5e7eb;
display: flex;
justify-content: center;
overflow: auto;
}
.print-preview-content iframe {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border-radius: 4px;
}
.print-option-group {
display: flex;
flex-direction: column;
gap: 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.print-option-group label {
font-size: 13px;
font-weight: 500;
color: var(--gray-600, #4b5563);
font-size: 13px;
font-weight: 500;
color: var(--gray-600, #4b5563);
}
.print-option-group select,
.print-option-group input[type="text"] {
padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 13px;
background: white;
.print-option-group input[type='text'] {
padding: 8px 10px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 13px;
background: white;
}
.scale-control {
display: flex;
align-items: center;
gap: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.scale-control input[type="range"] {
flex: 1;
.scale-control input[type='range'] {
flex: 1;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.print-range-input {
margin-top: 6px;
margin-top: 6px;
}
.print-actions {
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 16px;
display: flex;
gap: 8px;
margin-top: auto;
padding-top: 16px;
}
.btn-primary {
flex: 1;
padding: 10px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex: 1;
padding: 10px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
opacity: 0.9;
}
.btn-secondary {
flex: 1;
padding: 10px;
background: var(--gray-100, #f3f4f6);
color: var(--gray-700, #374151);
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
flex: 1;
padding: 10px;
background: var(--gray-100, #f3f4f6);
color: var(--gray-700, #374151);
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 8px;
font-size: 14px;
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);
}
+419 -174
View File
@@ -1,278 +1,523 @@
/* Sidebar */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.sidebar {
display: flex;
flex-shrink: 0;
height: 100%;
transition: width 0.2s ease;
display: flex;
flex-shrink: 0;
height: 100%;
transition: width 0.2s ease;
}
.sidebar.collapsed {
width: 48px;
width: 48px;
}
.sidebar:not(.collapsed) {
width: 328px; /* 48px icons + 280px panel */
width: 328px; /* 48px icons + 280px panel */
}
.sidebar-icons {
display: flex;
flex-direction: column;
width: 48px;
background: var(--gray-100, #f3f4f6);
border-right: 1px solid var(--gray-200, #e5e7eb);
padding: 8px 0;
gap: 4px;
align-items: center;
display: flex;
flex-direction: column;
width: 48px;
background: var(--gray-100, #f3f4f6);
border-right: 1px solid var(--gray-200, #e5e7eb);
padding: 8px 0;
gap: 4px;
align-items: center;
}
.sidebar-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--gray-500, #6b7280);
transition: all 0.15s ease;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: var(--gray-500, #6b7280);
transition: all 0.15s ease;
}
.sidebar-icon:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-700, #374151);
background: var(--gray-200, #e5e7eb);
color: var(--gray-700, #374151);
}
.sidebar-icon.active {
background: var(--gray-200, #e5e7eb);
color: var(--primary-dark, #5661b3);
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
background: var(--gray-200, #e5e7eb);
color: var(--primary-dark, #5661b3);
box-shadow: inset 3px 0 0 var(--primary-dark, #5661b3);
}
.sidebar-icon:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel {
width: 280px;
background: var(--gray-50, #f9fafb);
border-right: 1px solid var(--gray-200, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
width: 280px;
background: var(--gray-50, #f9fafb);
border-right: 1px solid var(--gray-200, #e5e7eb);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar.collapsed .sidebar-panel {
display: none;
display: none;
}
.sidebar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-200, #e5e7eb);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-600, #4b5563);
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-200, #e5e7eb);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400, #9ca3af);
padding: 0 4px;
border-radius: 4px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400, #9ca3af);
padding: 0 4px;
border-radius: 4px;
}
.sidebar-panel-close:hover {
background: var(--gray-200, #e5e7eb);
color: var(--gray-600, #4b5563);
background: var(--gray-200, #e5e7eb);
color: var(--gray-600, #4b5563);
}
.sidebar-panel-close:focus-visible {
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
outline: 2px solid var(--primary-dark, #5661b3);
outline-offset: 2px;
}
.sidebar-panel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* Dark theme support */
body[class*="dark"] .sidebar-icons,
body[class*="dark"] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
body[class*='dark'] .sidebar-icons,
body[class*='dark'] .sidebar-panel {
background: #1e1e1e;
border-color: #333;
}
body[class*="dark"] .sidebar-icon {
color: #888;
body[class*='dark'] .sidebar-icon {
color: #888;
}
body[class*="dark"] .sidebar-icon:hover {
background: #333;
color: #ccc;
body[class*='dark'] .sidebar-icon:hover {
background: #333;
color: #ccc;
}
body[class*="dark"] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
body[class*='dark'] .sidebar-icon.active {
background: #333;
color: #8b9aff;
box-shadow: inset 3px 0 0 #8b9aff;
}
body[class*="dark"] .sidebar-panel-header {
border-color: #333;
color: #ccc;
body[class*='dark'] .sidebar-panel-header {
border-color: #333;
color: #ccc;
}
/* Panel list items (templates, etc.) */
.panel-list-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.panel-list-item:hover {
background: var(--gray-200, #e5e7eb);
background: var(--gray-200, #e5e7eb);
}
.panel-list-item-title {
font-size: 13px;
font-weight: 500;
color: var(--gray-800, #1f2937);
font-size: 13px;
font-weight: 500;
color: var(--gray-800, #1f2937);
}
.panel-list-item-desc {
font-size: 11px;
color: var(--gray-500, #6b7280);
margin-top: 2px;
font-size: 11px;
color: var(--gray-500, #6b7280);
margin-top: 2px;
}
body[class*="dark"] .panel-list-item:hover {
background: #333;
body[class*='dark'] .panel-list-item:hover {
background: #333;
}
body[class*="dark"] .panel-list-item-title {
color: #ddd;
body[class*='dark'] .panel-list-item-title {
color: #ddd;
}
body[class*="dark"] .panel-list-item-desc {
color: #888;
body[class*='dark'] .panel-list-item-desc {
color: #888;
}
/* Explorer Panel */
.explorer-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.explorer-path {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
font-size: 12px;
background: white;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
padding: 6px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
font-size: 12px;
background: white;
overflow: hidden;
text-overflow: ellipsis;
}
.explorer-browse-btn {
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
padding: 4px 8px;
border: 1px solid var(--gray-300, #d1d5db);
border-radius: 6px;
background: white;
cursor: pointer;
padding: 4px 8px;
}
.tree-item {
padding: 3px 0;
cursor: pointer;
font-size: 13px;
user-select: none;
padding: 3px 0;
cursor: pointer;
font-size: 13px;
user-select: none;
}
.tree-item:hover {
background: var(--gray-100, #f3f4f6);
border-radius: 4px;
background: var(--gray-100, #f3f4f6);
border-radius: 4px;
}
.tree-icon {
margin-right: 4px;
font-size: 12px;
margin-right: 4px;
font-size: 12px;
}
.tree-children {
padding-left: 16px;
padding-left: 16px;
}
.tree-folder.collapsed .tree-children {
display: none;
display: none;
}
.tree-name {
font-size: 13px;
font-size: 13px;
}
/* Git Panel */
.git-section { margin-bottom: 16px; }
.git-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gray-500); margin-bottom: 8px; }
.git-file { display: flex; align-items: center; padding: 4px 6px; border-radius: 4px; font-size: 13px; gap: 6px; }
.git-file:hover { background: var(--gray-100, #f3f4f6); }
.git-file-status { font-weight: 700; font-family: monospace; width: 16px; text-align: center; }
.git-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.git-stage-btn { border: none; background: var(--gray-200); border-radius: 4px; cursor: pointer; font-size: 14px; width: 22px; height: 22px; }
.git-commit-input { width: 100%; padding: 8px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; font-family: inherit; resize: vertical; box-sizing: border-box; }
.git-commit-btn { width: 100%; margin-top: 8px; padding: 8px; background: var(--primary-dark, #5661b3); color: white; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; }
.git-commit-btn:hover { opacity: 0.9; }
.git-log-entry { padding: 6px 0; border-bottom: 1px solid var(--gray-100); }
.git-log-msg { font-size: 13px; }
.git-log-meta { font-size: 11px; color: var(--gray-500); margin-top: 2px; }
.git-info { font-size: 13px; color: var(--gray-500); padding: 8px 0; }
.git-loading { font-size: 13px; color: var(--gray-400); }
.git-section {
margin-bottom: 16px;
}
.git-section-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--gray-500);
margin-bottom: 8px;
}
.git-file {
display: flex;
align-items: center;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
gap: 6px;
}
.git-file:hover {
background: var(--gray-100, #f3f4f6);
}
.git-file-status {
font-weight: 700;
font-family: monospace;
width: 16px;
text-align: center;
}
.git-file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-stage-btn {
border: none;
background: var(--gray-200);
border-radius: 4px;
cursor: pointer;
font-size: 14px;
width: 22px;
height: 22px;
}
.git-commit-input {
width: 100%;
padding: 8px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.git-commit-btn {
width: 100%;
margin-top: 8px;
padding: 8px;
background: var(--primary-dark, #5661b3);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.git-commit-btn:hover {
opacity: 0.9;
}
.git-log-entry {
padding: 6px 0;
border-bottom: 1px solid var(--gray-100);
}
.git-log-msg {
font-size: 13px;
}
.git-log-meta {
font-size: 11px;
color: var(--gray-500);
margin-top: 2px;
}
.git-info {
font-size: 13px;
color: var(--gray-500);
padding: 8px 0;
}
.git-loading {
font-size: 13px;
color: var(--gray-400);
}
/* Snippets Panel */
.snippets-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
.snippets-search { flex: 1; padding: 6px 10px; border: 1px solid var(--gray-300); border-radius: 6px; font-size: 13px; }
.snippets-add-btn { width: 32px; border: 1px solid var(--gray-300); border-radius: 6px; background: white; font-size: 18px; cursor: pointer; }
.snippet-item { padding: 8px; border: 1px solid var(--gray-200); border-radius: 6px; margin-bottom: 6px; }
.snippet-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.snippet-name { font-size: 13px; font-weight: 500; }
.snippet-lang { font-size: 11px; background: var(--gray-100); padding: 2px 6px; border-radius: 4px; color: var(--gray-500); }
.snippet-preview { font-size: 12px; background: var(--gray-50); padding: 6px; border-radius: 4px; margin: 4px 0; overflow: hidden; max-height: 60px; }
.snippet-preview code { font-family: 'JetBrains Mono', monospace; }
.snippet-actions { display: flex; gap: 4px; }
.snippet-insert { font-size: 12px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; }
.snippet-delete { font-size: 14px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; background: white; cursor: pointer; color: #ef4444; }
.snippets-toolbar {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.snippets-search {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 13px;
}
.snippets-add-btn {
width: 32px;
border: 1px solid var(--gray-300);
border-radius: 6px;
background: white;
font-size: 18px;
cursor: pointer;
}
.snippet-item {
padding: 8px;
border: 1px solid var(--gray-200);
border-radius: 6px;
margin-bottom: 6px;
}
.snippet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.snippet-name {
font-size: 13px;
font-weight: 500;
}
.snippet-lang {
font-size: 11px;
background: var(--gray-100);
padding: 2px 6px;
border-radius: 4px;
color: var(--gray-500);
}
.snippet-preview {
font-size: 12px;
background: var(--gray-50);
padding: 6px;
border-radius: 4px;
margin: 4px 0;
overflow: hidden;
max-height: 60px;
}
.snippet-preview code {
font-family: 'JetBrains Mono', monospace;
}
.snippet-actions {
display: flex;
gap: 4px;
}
.snippet-insert {
font-size: 12px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
}
.snippet-delete {
font-size: 14px;
padding: 4px 8px;
border: 1px solid var(--gray-300);
border-radius: 4px;
background: white;
cursor: pointer;
color: #ef4444;
}
/* Dark theme for sidebar panels */
body[class*="dark"] .explorer-path,
body[class*="dark"] .explorer-browse-btn,
body[class*="dark"] .snippets-search,
body[class*="dark"] .snippets-add-btn,
body[class*="dark"] .snippet-insert,
body[class*="dark"] .snippet-delete {
background: #2d2d2d;
border-color: #444;
color: #ccc;
body[class*='dark'] .explorer-path,
body[class*='dark'] .explorer-browse-btn,
body[class*='dark'] .snippets-search,
body[class*='dark'] .snippets-add-btn,
body[class*='dark'] .snippet-insert,
body[class*='dark'] .snippet-delete {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
body[class*='dark'] .git-commit-input {
background: #2d2d2d;
border-color: #444;
color: #ccc;
}
body[class*="dark"] .snippet-item {
border-color: #444;
body[class*='dark'] .snippet-item {
border-color: #444;
}
body[class*="dark"] .snippet-preview {
background: #2d2d2d;
body[class*='dark'] .snippet-preview {
background: #2d2d2d;
}
body[class*="dark"] .tree-item:hover,
body[class*="dark"] .git-file:hover {
background: #333;
body[class*='dark'] .tree-item:hover,
body[class*='dark'] .git-file:hover {
background: #333;
}
/* Outline Panel */
.outline-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.outline-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.outline-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary, #6b7280);
transition:
background 0.15s,
color 0.15s;
border-left: 2px solid transparent;
}
.outline-item:hover {
background: var(--bg-tertiary, #f3f4f6);
color: var(--text-primary, #1f2937);
}
.outline-item.active {
color: var(--accent-blue, #3b82f6);
background: rgba(59, 130, 246, 0.08);
border-left-color: var(--accent-blue, #3b82f6);
font-weight: 600;
}
.outline-level-1 {
padding-left: 12px;
}
.outline-level-2 {
padding-left: 24px;
}
.outline-level-3 {
padding-left: 36px;
}
.outline-level-4 {
padding-left: 48px;
}
.outline-level-5 {
padding-left: 56px;
}
.outline-level-6 {
padding-left: 64px;
}
.outline-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-badge {
font-size: 10px;
opacity: 0.5;
margin-left: 8px;
flex-shrink: 0;
}
.outline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-muted, #9ca3af);
text-align: center;
}
.outline-empty p {
margin: 4px 0;
}
.outline-hint {
font-family: monospace;
font-size: 12px;
opacity: 0.7;
}
.outline-footer {
padding: 8px 12px;
border-top: 1px solid var(--border-color, #e5e7eb);
font-size: 11px;
color: var(--text-muted, #9ca3af);
}
/* Outline dark mode */
body[class*='dark'] .outline-item {
color: #9ca3af;
}
body[class*='dark'] .outline-item:hover {
background: #374151;
color: #e5e7eb;
}
body[class*='dark'] .outline-item.active {
color: #8b9aff;
background: rgba(139, 154, 255, 0.1);
border-left-color: #8b9aff;
}
body[class*='dark'] .outline-empty {
color: #6b7280;
}
body[class*='dark'] .outline-footer {
border-color: #333;
color: #6b7280;
}
+121 -24
View File
@@ -1,24 +1,121 @@
.welcome-container { padding: 40px; max-width: 900px; margin: 0 auto; }
.welcome-hero { text-align: center; margin-bottom: 40px; }
.welcome-title { font-size: 32px; font-weight: 700; color: var(--gray-800, #1f2937); }
.welcome-version { font-size: 14px; color: var(--primary-dark, #5661b3); margin-top: 4px; font-weight: 500; }
.welcome-subtitle { font-size: 16px; color: var(--gray-500, #6b7280); margin-top: 8px; }
.welcome-grid { display: grid; gap: 32px; }
.welcome-section h2 { font-size: 18px; margin-bottom: 16px; color: var(--gray-700, #374151); }
.welcome-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.welcome-card { padding: 20px; border: 1px solid var(--gray-200, #e5e7eb); border-radius: 12px; cursor: pointer; text-align: center; transition: all 0.2s; }
.welcome-card:hover { border-color: var(--primary-dark, #5661b3); box-shadow: 0 4px 12px rgba(0,0,0,0.08); transform: translateY(-2px); }
.welcome-card-icon { font-size: 28px; margin-bottom: 8px; }
.welcome-card h3 { font-size: 15px; margin-bottom: 4px; }
.welcome-card p { font-size: 13px; color: var(--gray-500); }
.welcome-card kbd { display: inline-block; margin-top: 8px; padding: 2px 8px; background: var(--gray-100); border: 1px solid var(--gray-300); border-radius: 4px; font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.welcome-features { list-style: none; padding: 0; }
.welcome-features li { padding: 6px 0; font-size: 14px; border-bottom: 1px solid var(--gray-100, #f3f4f6); }
.welcome-features strong { color: var(--primary-dark, #5661b3); }
.welcome-recent-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; }
.welcome-recent-item:hover { background: var(--gray-100); }
.welcome-recent-name { font-size: 14px; font-weight: 500; display: block; }
.welcome-recent-path { font-size: 12px; color: var(--gray-500); display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; }
.welcome-muted { color: var(--gray-400); font-size: 14px; }
.welcome-footer { margin-top: 32px; text-align: center; }
.welcome-checkbox { font-size: 13px; color: var(--gray-500); cursor: pointer; }
.welcome-container {
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
.welcome-hero {
text-align: center;
margin-bottom: 40px;
}
.welcome-title {
font-size: 32px;
font-weight: 700;
color: var(--gray-800, #1f2937);
}
.welcome-version {
font-size: 14px;
color: var(--primary-dark, #5661b3);
margin-top: 4px;
font-weight: 500;
}
.welcome-subtitle {
font-size: 16px;
color: var(--gray-500, #6b7280);
margin-top: 8px;
}
.welcome-grid {
display: grid;
gap: 32px;
}
.welcome-section h2 {
font-size: 18px;
margin-bottom: 16px;
color: var(--gray-700, #374151);
}
.welcome-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.welcome-card {
padding: 20px;
border: 1px solid var(--gray-200, #e5e7eb);
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.welcome-card:hover {
border-color: var(--primary-dark, #5661b3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.welcome-card-icon {
font-size: 28px;
margin-bottom: 8px;
}
.welcome-card h3 {
font-size: 15px;
margin-bottom: 4px;
}
.welcome-card p {
font-size: 13px;
color: var(--gray-500);
}
.welcome-card kbd {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
background: var(--gray-100);
border: 1px solid var(--gray-300);
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}
.welcome-features {
list-style: none;
padding: 0;
}
.welcome-features li {
padding: 6px 0;
font-size: 14px;
border-bottom: 1px solid var(--gray-100, #f3f4f6);
}
.welcome-features strong {
color: var(--primary-dark, #5661b3);
}
.welcome-recent-item {
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 4px;
}
.welcome-recent-item:hover {
background: var(--gray-100);
}
.welcome-recent-name {
font-size: 14px;
font-weight: 500;
display: block;
}
.welcome-recent-path {
font-size: 12px;
color: var(--gray-500);
display: block;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
}
.welcome-muted {
color: var(--gray-400);
font-size: 14px;
}
.welcome-footer {
margin-top: 32px;
text-align: center;
}
.welcome-checkbox {
font-size: 13px;
color: var(--gray-500);
cursor: pointer;
}
+102
View File
@@ -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);
}
+2016 -1783
View File
File diff suppressed because it is too large Load Diff
+129 -125
View File
@@ -1,7 +1,7 @@
/**
* Modal System Styles
* Unified modal components with glassmorphism backdrop
* @version 4.0.0
* @version 4.1.0
*/
/* ============================================
@@ -9,13 +9,13 @@
* ============================================ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 0;
cursor: pointer;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 0;
cursor: pointer;
}
/* ============================================
@@ -23,22 +23,23 @@
* ============================================ */
.modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-modal, 200) + 1);
opacity: 0;
visibility: hidden;
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));
padding: var(--spacing-4, 1rem);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-modal, 200) + 1);
opacity: 0;
visibility: hidden;
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));
padding: var(--spacing-4, 1rem);
}
.modal.open {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
/* ============================================
@@ -46,23 +47,23 @@
* ============================================ */
.modal-content {
position: relative;
z-index: 1;
width: 100%;
background: hsl(var(--background, 0 0% 100%));
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.95);
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
position: relative;
z-index: 1;
width: 100%;
background: hsl(var(--background, 0 0% 100%));
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1));
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
transform: scale(0.95);
transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1));
}
.modal.open .modal-content {
transform: scale(1);
transform: scale(1);
}
/* ============================================
@@ -70,19 +71,19 @@
* ============================================ */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
}
.modal-header h3 {
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: hsl(var(--foreground, 222.2 84% 4.9%));
margin: 0;
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
/* ============================================
@@ -90,31 +91,32 @@
* ============================================ */
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius, 0.5rem);
background: transparent;
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius, 0.5rem);
background: transparent;
color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%));
font-size: 24px;
line-height: 1;
cursor: pointer;
transition:
background-color var(--transition-fast, 150ms),
color var(--transition-fast, 150ms);
}
.modal-close:hover {
background: hsl(var(--accent, 210 40% 96.1%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
background: hsl(var(--accent, 210 40% 96.1%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-close:focus-visible {
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 2px;
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 2px;
}
/* ============================================
@@ -122,9 +124,9 @@
* ============================================ */
.modal-body {
padding: var(--spacing-6, 1.5rem);
overflow-y: auto;
flex: 1;
padding: var(--spacing-6, 1.5rem);
overflow-y: auto;
flex: 1;
}
/* ============================================
@@ -132,19 +134,19 @@
* ============================================ */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-3, 0.75rem);
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
display: flex;
justify-content: flex-end;
gap: var(--spacing-3, 0.75rem);
padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem);
border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
background: hsl(var(--muted, 210 40% 96.1%));
}
.modal-footer .btn {
min-width: 80px;
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
min-width: 80px;
/* styles-modern.css sets flex:1 on .btn-primary/.btn-secondary globally
* which causes footer buttons to stretch to full width. Override here. */
flex: none;
flex: none;
}
/* ============================================
@@ -152,60 +154,60 @@
* ============================================ */
.modal-body .form-row {
display: flex;
align-items: center;
gap: var(--spacing-3, 0.75rem);
margin-bottom: var(--spacing-3, 0.75rem);
display: flex;
align-items: center;
gap: var(--spacing-3, 0.75rem);
margin-bottom: var(--spacing-3, 0.75rem);
}
.modal-body .form-row label {
min-width: 100px;
font-size: var(--text-sm, 0.875rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
min-width: 100px;
font-size: var(--text-sm, 0.875rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .form-row input,
.modal-body .form-row select {
flex: 1;
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
border-radius: var(--radius, 0.5rem);
font-size: var(--text-sm, 0.875rem);
background: hsl(var(--background, 0 0% 100%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
flex: 1;
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%));
border-radius: var(--radius, 0.5rem);
font-size: var(--text-sm, 0.875rem);
background: hsl(var(--background, 0 0% 100%));
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .form-row input:focus,
.modal-body .form-row select:focus {
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 1px;
outline: 2px solid hsl(var(--ring, 227 44% 52%));
outline-offset: 1px;
}
.modal-body .export-section {
margin-bottom: var(--spacing-4, 1rem);
padding-bottom: var(--spacing-4, 1rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
margin-bottom: var(--spacing-4, 1rem);
padding-bottom: var(--spacing-4, 1rem);
border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%));
}
.modal-body .export-section:last-child {
border-bottom: none;
margin-bottom: 0;
border-bottom: none;
margin-bottom: 0;
}
.modal-body .export-section label {
display: block;
font-weight: var(--font-medium, 500);
margin-bottom: var(--spacing-2, 0.5rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
display: block;
font-weight: var(--font-medium, 500);
margin-bottom: var(--spacing-2, 0.5rem);
color: hsl(var(--foreground, 222.2 84% 4.9%));
}
.modal-body .checkbox-group label {
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
margin-bottom: var(--spacing-2, 0.5rem);
font-weight: var(--font-normal, 400);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
margin-bottom: var(--spacing-2, 0.5rem);
font-weight: var(--font-normal, 400);
cursor: pointer;
}
/* ============================================
@@ -213,19 +215,19 @@
* ============================================ */
.modal-content.small {
max-width: 400px;
min-width: 320px;
max-width: 400px;
min-width: 320px;
}
.modal-content.large {
max-width: 800px;
min-width: 560px;
max-width: 800px;
min-width: 560px;
}
.modal-content.full {
max-width: 95vw;
max-height: 95vh;
min-width: min(95vw, 700px);
max-width: 95vw;
max-height: 95vh;
min-width: min(95vw, 700px);
}
/* ============================================
@@ -233,16 +235,18 @@
* ============================================ */
.dark .modal-content,
[data-theme="dark"] .modal-content {
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);
[data-theme='dark'] .modal-content {
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);
}
.dark .modal-header,
.dark .modal-footer,
[data-theme="dark"] .modal-header,
[data-theme="dark"] .modal-footer {
background: hsl(var(--muted));
[data-theme='dark'] .modal-header,
[data-theme='dark'] .modal-footer {
background: hsl(var(--muted));
}
/* ============================================
@@ -250,10 +254,10 @@
* ============================================ */
@media (prefers-reduced-motion: reduce) {
.modal,
.modal-content {
transition: none;
}
.modal,
.modal-content {
transition: none;
}
}
/* ============================================
@@ -261,5 +265,5 @@
* ============================================ */
.modal.hidden {
display: none;
display: none;
}
+49 -17
View File
@@ -148,7 +148,7 @@
* ============================================ */
.dark,
[data-theme="dark"] {
[data-theme='dark'] {
/* Background and foreground */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
@@ -205,24 +205,56 @@
* Semantic Color Classes
* ============================================ */
.bg-background { background-color: hsl(var(--background)); }
.bg-foreground { background-color: hsl(var(--foreground)); }
.bg-card { 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)); }
.bg-background {
background-color: hsl(var(--background));
}
.bg-foreground {
background-color: hsl(var(--foreground));
}
.bg-card {
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-primary { 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)); }
.text-foreground {
color: hsl(var(--foreground));
}
.text-primary {
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-primary { border-color: hsl(var(--primary)); }
.border-input { border-color: hsl(var(--input)); }
.border-border {
border-color: hsl(var(--border));
}
.border-primary {
border-color: hsl(var(--primary));
}
.border-input {
border-color: hsl(var(--input));
}
/* ============================================
* Utility Classes
+491 -470
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -22,10 +22,10 @@ GET /resources
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) |
| Parameter | Type | Required | Description |
| --------- | ------- | -------- | ---------------------------- |
| page | integer | No | Page number (default: 1) |
| limit | integer | No | Items per page (default: 20) |
**Response:**
@@ -82,9 +82,9 @@ DELETE /resources/:id
## Error Codes
| Code | Description |
|------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
| 500 | Internal Server Error |
| Code | Description |
| ---- | --------------------- |
| 400 | Bad Request |
| 401 | Unauthorized |
| 404 | Not Found |
| 500 | Internal Server Error |
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Blog Post Title
date: {{DATE}}
date: { { DATE } }
author: Your Name
tags: []
---
+5
View File
@@ -3,18 +3,23 @@
## [Unreleased]
### Added
- New feature
### Changed
- Updated feature
### Fixed
- Bug fix
### Removed
- Removed feature
## [1.0.0] - {{DATE}}
### Added
- Initial release
+20 -16
View File
@@ -9,50 +9,54 @@ Brief description of what is being compared and why.
## Feature Comparison
| Feature | Option A | Option B |
|---------|----------|----------|
| Feature 1 | Yes | Yes |
| Feature 2 | Yes | No |
| Feature 3 | No | Yes |
| Feature 4 | Partial | Yes |
| Feature | Option A | Option B |
| --------- | -------- | -------- |
| Feature 1 | Yes | Yes |
| Feature 2 | Yes | No |
| Feature 3 | No | Yes |
| Feature 4 | Partial | Yes |
## Pricing
| Plan | Option A | Option B |
|------|----------|----------|
| Free | Limited | Limited |
| Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom |
| Plan | Option A | Option B |
| ---------- | -------- | -------- |
| Free | Limited | Limited |
| Pro | $10/mo | $15/mo |
| Enterprise | Custom | Custom |
## Pros and Cons
### Option A
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
### Option B
**Pros:**
- Pro 1
- Pro 2
**Cons:**
- Con 1
- Con 2
## Performance
| Metric | Option A | Option B |
|--------|----------|----------|
| Speed | Fast | Moderate |
| Memory | Low | Medium |
| Scalability | Good | Excellent |
| Metric | Option A | Option B |
| ----------- | -------- | --------- |
| Speed | Fast | Moderate |
| Memory | Low | Medium |
| Scalability | Good | Excellent |
## Recommendation
+1
View File
@@ -2,6 +2,7 @@
**Date:** {{DATE}}
**Attendees:**
- Name 1
- Name 2
+17 -12
View File
@@ -12,43 +12,48 @@
## Scope
### In Scope
- Item 1
- Item 2
### Out of Scope
- Item 1
## Milestones
| Milestone | Target Date | Status |
|-----------|-------------|--------|
| Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started |
| Launch | TBD | Not Started |
| Milestone | Target Date | Status |
| --------- | ----------- | ----------- |
| Kickoff | {{DATE}} | Not Started |
| MVP | TBD | Not Started |
| Launch | TBD | Not Started |
## Resources
| Role | Person | Allocation |
|------|--------|------------|
| Lead | Name | 100% |
| Developer | Name | 50% |
| Role | Person | Allocation |
| --------- | ------ | ---------- |
| Lead | Name | 100% |
| Developer | Name | 50% |
## Risks
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Risk 1 | High | Medium | Plan |
| Risk | Impact | Likelihood | Mitigation |
| ------ | ------ | ---------- | ---------- |
| Risk 1 | High | Medium | Plan |
## Timeline
### Phase 1: Planning
- [ ] Define requirements
- [ ] Create design documents
### Phase 2: Development
- [ ] Implement core features
- [ ] Write tests
### Phase 3: Launch
- [ ] Deploy to production
- [ ] Monitor and iterate
+3 -3
View File
@@ -21,9 +21,9 @@ const project = require('project-name');
## Configuration
| Option | Default | Description |
|--------|---------|-------------|
| option1 | true | Description |
| Option | Default | Description |
| ------- | ------- | ----------- |
| option1 | true | Description |
## Contributing
+8 -6
View File
@@ -12,10 +12,12 @@ Brief description of what this feature does.
## Requirements
### Functional Requirements
1. Requirement 1
2. Requirement 2
### Non-Functional Requirements
1. Performance requirement
2. Security requirement
@@ -27,9 +29,9 @@ Describe the technical approach.
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /api/resource | Get resource |
| Method | Path | Description |
| ------ | ------------- | ------------ |
| GET | /api/resource | Get resource |
## Testing Strategy
@@ -37,6 +39,6 @@ Describe how this will be tested.
## Timeline
| Phase | Duration | Deliverables |
|-------|----------|-------------|
| Phase 1 | 1 week | Core implementation |
| Phase | Duration | Deliverables |
| ------- | -------- | ------------------- |
| Phase 1 | 1 week | Core implementation |
+3 -1
View File
@@ -42,7 +42,7 @@ Walk through the implementation.
```javascript
// Example code
function example() {
return 'Hello';
return 'Hello';
}
```
@@ -53,10 +53,12 @@ How to verify everything works.
## Common Issues
### Issue 1
**Problem:** Description of the problem.
**Solution:** How to fix it.
### Issue 2
**Problem:** Description of the problem.
**Solution:** How to fix it.
+206 -205
View File
@@ -1,245 +1,246 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.0.0
* @version 4.4.5
*/
class ModalManager {
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
static #openModals = [];
static #openModals = [];
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options,
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Setup close triggers
this.#setupCloseTriggers();
}
// Setup close triggers
this.#setupCloseTriggers();
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach((el) => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector))
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
open() {
if (this.#isOpen) return;
// Store last focused element
this.#lastFocusedElement = document.activeElement;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
open() {
if (this.#isOpen) return;
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
}
// Store last focused element
this.#lastFocusedElement = document.activeElement;
close() {
if (!this.#isOpen) return;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
close() {
if (!this.#isOpen) return;
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
}
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
isOpen() {
return this.#isOpen;
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
(l) => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
}
isOpen() {
return this.#isOpen;
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
}
}
// Export for use in renderer
if (typeof window !== 'undefined') {
window.ModalManager = ModalManager;
// Export for use in renderer - avoid duplicate declaration
if (typeof window !== 'undefined' && !window.ModalManager) {
window.ModalManager = ModalManager;
}
// CommonJS export
+12 -10
View File
@@ -1,16 +1,18 @@
function createWelcomeContent(recentFiles = []) {
const recentHtml = recentFiles.length
? recentFiles.map(f => {
const name = f.split(/[/\\]/).pop();
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
}).join('')
: '<p class="welcome-muted">No recent files</p>';
function createWelcomeContent(recentFiles = [], appVersion = '') {
const recentHtml = recentFiles.length
? recentFiles
.map((f) => {
const name = f.split(/[/\\]/).pop();
return `<div class="welcome-recent-item" data-path="${f}"><span class="welcome-recent-name">${name}</span><span class="welcome-recent-path">${f}</span></div>`;
})
.join('')
: '<p class="welcome-muted">No recent files</p>';
return `
return `
<div class="welcome-container">
<div class="welcome-hero">
<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>
</div>
@@ -45,7 +47,7 @@ function createWelcomeContent(recentFiles = []) {
</div>
<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">
<li><strong>CodeMirror Editor</strong> Syntax highlighting, code folding, multiple cursors</li>
<li><strong>Sidebar Panels</strong> File Explorer, Git, Snippets, Templates</li>
Binary file not shown.
+284
View File
@@ -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">&middot;</span>
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
<span class="zen-hud-sep">&middot;</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 };
+28
View File
@@ -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);
}
});
});
+110 -110
View File
@@ -8,10 +8,10 @@
*/
describe('CommandPalette', () => {
let CommandPalette;
let CommandPalette;
beforeEach(() => {
document.body.innerHTML = `
beforeEach(() => {
document.body.innerHTML = `
<div class="command-palette-overlay hidden" id="command-palette-overlay">
<div class="command-palette">
<input type="text" id="command-palette-input">
@@ -19,126 +19,126 @@ describe('CommandPalette', () => {
</div>
</div>
`;
CommandPalette = require('../src/command-palette').CommandPalette;
});
CommandPalette = require('../src/command-palette').CommandPalette;
});
test('starts hidden', () => {
const palette = new CommandPalette();
expect(palette.isOpen()).toBe(false);
});
test('starts hidden', () => {
const palette = new CommandPalette();
expect(palette.isOpen()).toBe(false);
});
test('opens and focuses input', () => {
const palette = new CommandPalette();
palette.open();
expect(palette.isOpen()).toBe(true);
});
test('opens and focuses input', () => {
const palette = new CommandPalette();
palette.open();
expect(palette.isOpen()).toBe(true);
});
test('closes', () => {
const palette = new CommandPalette();
palette.open();
palette.close();
expect(palette.isOpen()).toBe(false);
});
test('closes', () => {
const palette = new CommandPalette();
palette.open();
palette.close();
expect(palette.isOpen()).toBe(false);
});
test('registers and renders commands', () => {
const palette = new CommandPalette();
palette.register('Test Command', 'Ctrl+T', () => {});
palette.register('Another Command', '', () => {});
palette.open();
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('registers and renders commands', () => {
const palette = new CommandPalette();
palette.register('Test Command', 'Ctrl+T', () => {});
palette.register('Another Command', '', () => {});
palette.open();
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('filters commands by search', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.register('Open File', 'Ctrl+O', () => {});
palette.register('Bold Text', 'Ctrl+B', () => {});
palette.renderResults('file');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('filters commands by search', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.register('Open File', 'Ctrl+O', () => {});
palette.register('Bold Text', 'Ctrl+B', () => {});
palette.renderResults('file');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(2);
});
test('executes command', () => {
const palette = new CommandPalette();
const action = jest.fn();
palette.register('Test', '', action);
palette.open();
palette.executeSelected();
expect(action).toHaveBeenCalled();
});
test('executes command', () => {
const palette = new CommandPalette();
const action = jest.fn();
palette.register('Test', '', action);
palette.open();
palette.executeSelected();
expect(action).toHaveBeenCalled();
});
test('highlights matching text', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', 'save');
expect(result).toContain('<strong>');
expect(result).toContain('Save');
});
test('highlights matching text', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', 'save');
expect(result).toContain('<strong>');
expect(result).toContain('Save');
});
test('highlightMatch returns original text when no query', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', '');
expect(result).toBe('Save File');
});
test('highlightMatch returns original text when no query', () => {
const palette = new CommandPalette();
const result = palette.highlightMatch('Save File', '');
expect(result).toBe('Save File');
});
test('filters case-insensitively', () => {
const palette = new CommandPalette();
palette.register('Save File', '', () => {});
palette.register('Open File', '', () => {});
palette.renderResults('SAVE');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(1);
});
test('filters case-insensitively', () => {
const palette = new CommandPalette();
palette.register('Save File', '', () => {});
palette.register('Open File', '', () => {});
palette.renderResults('SAVE');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(1);
});
test('shows all commands when query is empty', () => {
const palette = new CommandPalette();
palette.register('Cmd1', '', () => {});
palette.register('Cmd2', '', () => {});
palette.register('Cmd3', '', () => {});
palette.renderResults('');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(3);
});
test('shows all commands when query is empty', () => {
const palette = new CommandPalette();
palette.register('Cmd1', '', () => {});
palette.register('Cmd2', '', () => {});
palette.register('Cmd3', '', () => {});
palette.renderResults('');
const items = document.querySelectorAll('.command-item');
expect(items.length).toBe(3);
});
test('displays shortcut when provided', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).not.toBeNull();
expect(shortcut.textContent).toBe('Ctrl+S');
});
test('displays shortcut when provided', () => {
const palette = new CommandPalette();
palette.register('Save File', 'Ctrl+S', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).not.toBeNull();
expect(shortcut.textContent).toBe('Ctrl+S');
});
test('does not display shortcut when empty', () => {
const palette = new CommandPalette();
palette.register('Test Command', '', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).toBeNull();
});
test('does not display shortcut when empty', () => {
const palette = new CommandPalette();
palette.register('Test Command', '', () => {});
palette.open();
const shortcut = document.querySelector('.command-shortcut');
expect(shortcut).toBeNull();
});
test('closes on overlay click', () => {
const palette = new CommandPalette();
palette.open();
// Simulate click on the overlay itself
const event = new Event('click', { bubbles: true });
Object.defineProperty(event, 'target', { value: palette.overlay });
palette.overlay.dispatchEvent(event);
expect(palette.isOpen()).toBe(false);
});
test('closes on overlay click', () => {
const palette = new CommandPalette();
palette.open();
// Simulate click on the overlay itself
const event = new Event('click', { bubbles: true });
Object.defineProperty(event, 'target', { value: palette.overlay });
palette.overlay.dispatchEvent(event);
expect(palette.isOpen()).toBe(false);
});
test('executeSelected does nothing when no commands', () => {
const palette = new CommandPalette();
palette.open();
// Should not throw
expect(() => palette.executeSelected()).not.toThrow();
});
test('executeSelected does nothing when no commands', () => {
const palette = new CommandPalette();
palette.open();
// Should not throw
expect(() => palette.executeSelected()).not.toThrow();
});
test('closes after executing command', () => {
const palette = new CommandPalette();
palette.register('Test', '', jest.fn());
palette.open();
palette.executeSelected();
expect(palette.isOpen()).toBe(false);
});
test('closes after executing command', () => {
const palette = new CommandPalette();
palette.register('Test', '', jest.fn());
palette.open();
palette.executeSelected();
expect(palette.isOpen()).toBe(false);
});
});
+66
View File
@@ -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']);
});
});
+117
View File
@@ -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);
});
});
});
+84
View File
@@ -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);
});
});
+18
View File
@@ -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/);
});
});
+76 -77
View File
@@ -4,97 +4,96 @@
*/
describe('sanitizeErrorMessage', () => {
const sanitizeErrorMessage = (message) => {
if (typeof message !== 'string') return String(message);
return message
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
};
const sanitizeErrorMessage = (message) => {
if (typeof message !== 'string') return String(message);
return message
.replace(/[A-Z]:\\[^\s"']+\\([^\s"'\\]+)/gi, '$1')
.replace(/\/[^\s"']+\/([^\s"'/]+)/g, '$1');
};
test('strips Windows absolute paths', () => {
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js'))
.toBe('Error in file.js');
});
test('strips Windows absolute paths', () => {
expect(sanitizeErrorMessage('Error in C:\\Users\\test\\file.js')).toBe('Error in file.js');
});
test('strips Unix absolute paths', () => {
expect(sanitizeErrorMessage('Error in /home/user/project/file.js'))
.toBe('Error in file.js');
});
test('strips Unix absolute paths', () => {
expect(sanitizeErrorMessage('Error in /home/user/project/file.js')).toBe('Error in file.js');
});
test('handles non-string input', () => {
expect(sanitizeErrorMessage(42)).toBe('42');
expect(sanitizeErrorMessage(null)).toBe('null');
});
test('handles non-string input', () => {
expect(sanitizeErrorMessage(42)).toBe('42');
expect(sanitizeErrorMessage(null)).toBe('null');
});
test('preserves messages without paths', () => {
expect(sanitizeErrorMessage('Something went wrong'))
.toBe('Something went wrong');
});
test('preserves messages without paths', () => {
expect(sanitizeErrorMessage('Something went wrong')).toBe('Something went wrong');
});
test('strips nested Windows paths', () => {
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json'))
.toBe('Cannot read config.json');
});
test('strips nested Windows paths', () => {
expect(sanitizeErrorMessage('Cannot read C:\\Users\\admin\\AppData\\Local\\config.json')).toBe(
'Cannot read config.json'
);
});
test('strips nested Unix paths', () => {
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log'))
.toBe('File not found: error.log');
});
test('strips nested Unix paths', () => {
expect(sanitizeErrorMessage('File not found: /var/log/app/error.log')).toBe(
'File not found: error.log'
);
});
test('handles undefined input', () => {
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
});
test('handles undefined input', () => {
expect(sanitizeErrorMessage(undefined)).toBe('undefined');
});
});
describe('createRateLimiter', () => {
const createRateLimiter = (minIntervalMs = 2000) => {
let lastCall = 0;
return function canProceed() {
const now = Date.now();
if (now - lastCall < minIntervalMs) return false;
lastCall = now;
return true;
};
const createRateLimiter = (minIntervalMs = 2000) => {
let lastCall = 0;
return function canProceed() {
const now = Date.now();
if (now - lastCall < minIntervalMs) return false;
lastCall = now;
return true;
};
};
test('allows first call', () => {
const limiter = createRateLimiter(1000);
expect(limiter()).toBe(true);
});
test('allows first call', () => {
const limiter = createRateLimiter(1000);
expect(limiter()).toBe(true);
});
test('blocks rapid calls', () => {
const limiter = createRateLimiter(1000);
limiter(); // first call
expect(limiter()).toBe(false); // too soon
});
test('blocks rapid calls', () => {
const limiter = createRateLimiter(1000);
limiter(); // first call
expect(limiter()).toBe(false); // too soon
});
test('allows call after interval', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(1000);
limiter();
jest.advanceTimersByTime(1001);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('allows call after interval', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(1000);
limiter();
jest.advanceTimersByTime(1001);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('uses default interval of 2000ms', () => {
jest.useFakeTimers();
const limiter = createRateLimiter();
limiter();
jest.advanceTimersByTime(1999);
expect(limiter()).toBe(false);
jest.advanceTimersByTime(2);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('uses default interval of 2000ms', () => {
jest.useFakeTimers();
const limiter = createRateLimiter();
limiter();
jest.advanceTimersByTime(1999);
expect(limiter()).toBe(false);
jest.advanceTimersByTime(2);
expect(limiter()).toBe(true);
jest.useRealTimers();
});
test('resets after successful call', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(500);
limiter();
jest.advanceTimersByTime(501);
limiter(); // resets the timer
expect(limiter()).toBe(false); // too soon after second call
jest.useRealTimers();
});
test('resets after successful call', () => {
jest.useFakeTimers();
const limiter = createRateLimiter(500);
limiter();
jest.advanceTimersByTime(501);
limiter(); // resets the timer
expect(limiter()).toBe(false); // too soon after second call
jest.useRealTimers();
});
});
+247 -115
View File
@@ -4,127 +4,259 @@
*/
describe('Markdown Extensions', () => {
describe('TOC generation from headings', () => {
test('extracts all heading levels', () => {
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(4);
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
});
test('handles empty HTML', () => {
const html = '<p>No headings here</p>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(0);
});
test('handles headings with attributes', () => {
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(2);
expect(toc[0].text).toBe('Title');
});
describe('TOC generation from headings', () => {
test('extracts all heading levels', () => {
const html = '<h1>Title</h1><h2>Section 1</h2><h2>Section 2</h2><h3>Subsection</h3>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(4);
expect(toc[0]).toEqual({ level: 1, text: 'Title' });
expect(toc[3]).toEqual({ level: 3, text: 'Subsection' });
});
describe('Admonition regex matching', () => {
test('matches note admonition', () => {
const src = ':::note\nThis is a note.\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe('note');
expect(match[2].trim()).toBe('This is a note.');
});
test('matches all admonition types', () => {
const types = ['note', 'warning', 'tip', 'danger', 'info'];
types.forEach(type => {
const src = `:::${type}\nContent\n:::\n`;
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe(type);
});
});
test('does not match invalid admonition type', () => {
const src = ':::custom\nContent\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).toBeNull();
});
test('captures multiline content', () => {
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[2]).toContain('Line 1');
expect(match[2]).toContain('Line 3');
});
test('handles empty HTML', () => {
const html = '<p>No headings here</p>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(0);
});
describe('PlantUML hex encoding', () => {
const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8'))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return '~h' + hex;
};
test('handles headings with attributes', () => {
const html = '<h1 id="title" class="main">Title</h1><h2 id="sec">Section</h2>';
const headingRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/gi;
const toc = [];
let match;
while ((match = headingRegex.exec(html)) !== null) {
toc.push({ level: parseInt(match[1]), text: match[2] });
}
expect(toc).toHaveLength(2);
expect(toc[0].text).toBe('Title');
});
});
test('encodes simple text', () => {
const encoded = plantumlEncode('A -> B');
expect(encoded).toBe('~h41202d3e2042');
});
test('encodes empty string', () => {
expect(plantumlEncode('')).toBe('~h');
});
test('encodes special characters', () => {
const encoded = plantumlEncode('@startuml');
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
// '@' is 0x40, 's' is 0x73
expect(encoded.startsWith('~h40')).toBe(true);
});
describe('Admonition regex matching', () => {
test('matches note admonition', () => {
const src = ':::note\nThis is a note.\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe('note');
expect(match[2].trim()).toBe('This is a note.');
});
describe('Slug generation for TOC anchors', () => {
const slugify = (text) => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
test('converts heading to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('removes special characters', () => {
expect(slugify('What is C++?')).toBe('what-is-c');
});
test('collapses multiple dashes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('handles already lowercase text', () => {
expect(slugify('simple')).toBe('simple');
});
test('matches all admonition types', () => {
const types = ['note', 'warning', 'tip', 'danger', 'info'];
types.forEach((type) => {
const src = `:::${type}\nContent\n:::\n`;
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[1]).toBe(type);
});
});
test('does not match invalid admonition type', () => {
const src = ':::custom\nContent\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).toBeNull();
});
test('captures multiline content', () => {
const src = ':::warning\nLine 1\nLine 2\nLine 3\n:::\n';
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
expect(match).not.toBeNull();
expect(match[2]).toContain('Line 1');
expect(match[2]).toContain('Line 3');
});
});
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', () => {
const plantumlEncode = (text) => {
const hex = Array.from(Buffer.from(text, 'utf-8'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return '~h' + hex;
};
test('encodes simple text', () => {
const encoded = plantumlEncode('A -> B');
expect(encoded).toBe('~h41202d3e2042');
});
test('encodes empty string', () => {
expect(plantumlEncode('')).toBe('~h');
});
test('encodes special characters', () => {
const encoded = plantumlEncode('@startuml');
expect(encoded).toMatch(/^~h[0-9a-f]+$/);
// '@' is 0x40, 's' is 0x73
expect(encoded.startsWith('~h40')).toBe(true);
});
});
describe('Slug generation for TOC anchors', () => {
const slugify = (text) => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
test('converts heading to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('removes special characters', () => {
expect(slugify('What is C++?')).toBe('what-is-c');
});
test('collapses multiple dashes', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
test('handles already lowercase text', () => {
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('');
});
});
});
+234 -234
View File
@@ -9,272 +9,272 @@
const { ModalManager } = require('../src/utils/ModalManager');
function createModalElement(id = 'test-modal') {
const modal = document.createElement('div');
modal.id = id;
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const modal = document.createElement('div');
modal.id = id;
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.setAttribute('data-close', '');
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.setAttribute('data-close', '');
const content = document.createElement('div');
content.className = 'modal-content';
const content = document.createElement('div');
content.className = 'modal-content';
const header = document.createElement('div');
header.className = 'modal-header';
const header = document.createElement('div');
header.className = 'modal-header';
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', 'Close');
const body = document.createElement('div');
body.className = 'modal-body';
const body = document.createElement('div');
body.className = 'modal-body';
const input = document.createElement('input');
input.type = 'text';
const input = document.createElement('input');
input.type = 'text';
body.appendChild(input);
header.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
modal.appendChild(backdrop);
modal.appendChild(content);
document.body.appendChild(modal);
body.appendChild(input);
header.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
modal.appendChild(backdrop);
modal.appendChild(content);
document.body.appendChild(modal);
return modal;
return modal;
}
describe('ModalManager', () => {
let modal;
let manager;
let modal;
let manager;
beforeEach(() => {
modal = createModalElement();
manager = new ModalManager(modal);
});
afterEach(() => {
manager.destroy();
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
document.body.style.overflow = '';
});
// =========================================================
// open()
// =========================================================
describe('open()', () => {
test('removes hidden class and adds open class', () => {
expect(modal.classList.contains('hidden')).toBe(true);
expect(modal.classList.contains('open')).toBe(false);
manager.open();
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
});
test('sets isOpen to true', () => {
expect(manager.isOpen()).toBe(false);
manager.open();
expect(manager.isOpen()).toBe(true);
});
test('prevents body scroll', () => {
manager.open();
expect(document.body.style.overflow).toBe('hidden');
});
test('does not open again if already open', () => {
manager.open();
manager.open(); // second call should be no-op
expect(manager.isOpen()).toBe(true);
});
test('calls onOpen callback', () => {
const onOpen = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onOpen });
manager.open();
expect(onOpen).toHaveBeenCalledTimes(1);
});
test('dispatches modal:open custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:open', handler);
manager.open();
expect(handler).toHaveBeenCalledTimes(1);
});
});
// =========================================================
// close()
// =========================================================
describe('close()', () => {
beforeEach(() => {
modal = createModalElement();
manager = new ModalManager(modal);
manager.open();
});
afterEach(() => {
manager.destroy();
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
document.body.style.overflow = '';
test('removes open class', () => {
expect(modal.classList.contains('open')).toBe(true);
manager.close();
expect(modal.classList.contains('open')).toBe(false);
});
// =========================================================
// open()
// =========================================================
describe('open()', () => {
test('removes hidden class and adds open class', () => {
expect(modal.classList.contains('hidden')).toBe(true);
expect(modal.classList.contains('open')).toBe(false);
manager.open();
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
});
test('sets isOpen to true', () => {
expect(manager.isOpen()).toBe(false);
manager.open();
expect(manager.isOpen()).toBe(true);
});
test('prevents body scroll', () => {
manager.open();
expect(document.body.style.overflow).toBe('hidden');
});
test('does not open again if already open', () => {
manager.open();
manager.open(); // second call should be no-op
expect(manager.isOpen()).toBe(true);
});
test('calls onOpen callback', () => {
const onOpen = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onOpen });
manager.open();
expect(onOpen).toHaveBeenCalledTimes(1);
});
test('dispatches modal:open custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:open', handler);
manager.open();
expect(handler).toHaveBeenCalledTimes(1);
});
test('sets isOpen to false immediately', () => {
manager.close();
expect(manager.isOpen()).toBe(false);
});
// =========================================================
// close()
// =========================================================
describe('close()', () => {
beforeEach(() => {
manager.open();
});
test('removes open class', () => {
expect(modal.classList.contains('open')).toBe(true);
manager.close();
expect(modal.classList.contains('open')).toBe(false);
});
test('sets isOpen to false immediately', () => {
manager.close();
expect(manager.isOpen()).toBe(false);
});
test('restores body scroll when no modals remain open', () => {
manager.close();
expect(document.body.style.overflow).toBe('');
});
test('adds hidden class after transitionend event fires', () => {
manager.close();
// Immediately after close(): hidden should NOT yet be added —
// the close animation is still in progress.
// (This is the bug that existed before the fix.)
expect(modal.classList.contains('hidden')).toBe(false);
// Simulate the CSS transition completing
const event = new Event('transitionend');
Object.defineProperty(event, 'target', { value: modal, writable: false });
modal.dispatchEvent(event);
expect(modal.classList.contains('hidden')).toBe(true);
});
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
jest.useFakeTimers();
manager.close();
expect(modal.classList.contains('hidden')).toBe(false);
// Advance past the fallback timeout (250ms)
jest.advanceTimersByTime(300);
expect(modal.classList.contains('hidden')).toBe(true);
jest.useRealTimers();
});
test('does not add hidden class if modal is reopened before timeout fires', () => {
jest.useFakeTimers();
manager.close();
jest.advanceTimersByTime(100); // halfway through timeout
// Re-open the modal before the timeout fires
manager.open();
jest.advanceTimersByTime(200); // past original timeout expiry
// Modal was reopened, so hidden must NOT have been added
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
jest.useRealTimers();
});
test('calls onClose callback', () => {
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.open();
manager.close();
expect(onClose).toHaveBeenCalledTimes(1);
});
test('dispatches modal:close custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:close', handler);
manager.close();
expect(handler).toHaveBeenCalledTimes(1);
});
test('is a no-op when modal is already closed', () => {
manager.close(); // close from open
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.close(); // call close on an already-closed modal
expect(onClose).not.toHaveBeenCalled();
});
test('restores body scroll when no modals remain open', () => {
manager.close();
expect(document.body.style.overflow).toBe('');
});
// =========================================================
// Keyboard interaction
// =========================================================
test('adds hidden class after transitionend event fires', () => {
manager.close();
describe('keyboard shortcuts', () => {
test('Escape key closes an open modal', () => {
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(false);
});
// Immediately after close(): hidden should NOT yet be added —
// the close animation is still in progress.
// (This is the bug that existed before the fix.)
expect(modal.classList.contains('hidden')).toBe(false);
test('Escape key does nothing when modal is already closed', () => {
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}).not.toThrow();
});
// Simulate the CSS transition completing
const event = new Event('transitionend');
Object.defineProperty(event, 'target', { value: modal, writable: false });
modal.dispatchEvent(event);
test('closeOnEscape: false prevents Escape from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnEscape: false });
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(true);
});
expect(modal.classList.contains('hidden')).toBe(true);
});
// =========================================================
// Close triggers
// =========================================================
test('adds hidden class via 250ms timeout fallback when transitionend never fires', () => {
jest.useFakeTimers();
describe('close triggers', () => {
test('clicking the × close button closes the modal', () => {
manager.open();
modal.querySelector('.modal-close').click();
expect(manager.isOpen()).toBe(false);
});
manager.close();
expect(modal.classList.contains('hidden')).toBe(false);
test('clicking backdrop (data-close) closes the modal', () => {
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(false);
});
// Advance past the fallback timeout (250ms)
jest.advanceTimersByTime(300);
test('closeOnBackdrop: false prevents backdrop from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnBackdrop: false });
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(true);
});
expect(modal.classList.contains('hidden')).toBe(true);
jest.useRealTimers();
});
// =========================================================
// destroy()
// =========================================================
test('does not add hidden class if modal is reopened before timeout fires', () => {
jest.useFakeTimers();
describe('destroy()', () => {
test('closes modal if open', () => {
manager.open();
manager.destroy();
expect(manager.isOpen()).toBe(false);
});
manager.close();
jest.advanceTimersByTime(100); // halfway through timeout
test('does not throw when destroying a closed modal', () => {
expect(() => manager.destroy()).not.toThrow();
});
// Re-open the modal before the timeout fires
manager.open();
jest.advanceTimersByTime(200); // past original timeout expiry
// Modal was reopened, so hidden must NOT have been added
expect(modal.classList.contains('hidden')).toBe(false);
expect(modal.classList.contains('open')).toBe(true);
jest.useRealTimers();
});
test('calls onClose callback', () => {
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.open();
manager.close();
expect(onClose).toHaveBeenCalledTimes(1);
});
test('dispatches modal:close custom event', () => {
const handler = jest.fn();
modal.addEventListener('modal:close', handler);
manager.close();
expect(handler).toHaveBeenCalledTimes(1);
});
test('is a no-op when modal is already closed', () => {
manager.close(); // close from open
const onClose = jest.fn();
manager.destroy();
manager = new ModalManager(modal, { onClose });
manager.close(); // call close on an already-closed modal
expect(onClose).not.toHaveBeenCalled();
});
});
// =========================================================
// Keyboard interaction
// =========================================================
describe('keyboard shortcuts', () => {
test('Escape key closes an open modal', () => {
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(false);
});
test('Escape key does nothing when modal is already closed', () => {
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
}).not.toThrow();
});
test('closeOnEscape: false prevents Escape from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnEscape: false });
manager.open();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.isOpen()).toBe(true);
});
});
// =========================================================
// Close triggers
// =========================================================
describe('close triggers', () => {
test('clicking the × close button closes the modal', () => {
manager.open();
modal.querySelector('.modal-close').click();
expect(manager.isOpen()).toBe(false);
});
test('clicking backdrop (data-close) closes the modal', () => {
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(false);
});
test('closeOnBackdrop: false prevents backdrop from closing', () => {
manager.destroy();
manager = new ModalManager(modal, { closeOnBackdrop: false });
manager.open();
modal.querySelector('.modal-backdrop').click();
expect(manager.isOpen()).toBe(true);
});
});
// =========================================================
// destroy()
// =========================================================
describe('destroy()', () => {
test('closes modal if open', () => {
manager.open();
manager.destroy();
expect(manager.isOpen()).toBe(false);
});
test('does not throw when destroying a closed modal', () => {
expect(() => manager.destroy()).not.toThrow();
});
});
});
+19
View File
@@ -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);
});
});
});
+92
View File
@@ -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);
}
});
});
});
});
+29
View File
@@ -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');
});
});
+90
View File
@@ -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);
});
});
+116
View File
@@ -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');
});
});
+156
View File
@@ -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
View File
@@ -54,6 +54,14 @@ describe('Preload Security', () => {
'save-pasted-image',
'load-template',
'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
'open-file-path',
'git-status',
'git-stage',
@@ -66,7 +74,7 @@ describe('Preload Security', () => {
'execute-code',
'show-pdf-editor-from-toolbar',
'menu-open',
'export'
'export',
];
const EXPECTED_RECEIVE_CHANNELS = [
@@ -119,7 +127,7 @@ describe('Preload Security', () => {
'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel',
'toggle-bottom-panel'
'toggle-bottom-panel',
];
test('should define all expected send channels', () => {
+82 -70
View File
@@ -8,13 +8,17 @@
*/
describe('PrintPreview', () => {
beforeEach(() => {
// Mock electron require for executePrint
jest.mock('electron', () => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() }
}), { virtual: true });
beforeEach(() => {
// Mock electron require for executePrint
jest.mock(
'electron',
() => ({
ipcRenderer: { send: jest.fn(), invoke: jest.fn() },
}),
{ virtual: true }
);
document.body.innerHTML = `
document.body.innerHTML = `
<div class="dialog-overlay hidden" id="print-preview-overlay">
<button id="print-preview-close"></button>
<button id="print-cancel"></button>
@@ -31,79 +35,87 @@ describe('PrintPreview', () => {
<iframe id="print-preview-frame"></iframe>
</div>
`;
});
});
test('getOptions returns default values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
const options = preview.getOptions();
expect(options.paperSize).toBe('A4');
expect(options.orientation).toBe('portrait');
expect(options.scale).toBe(100);
expect(options.headers).toBe(true);
expect(options.background).toBe(true);
expect(options.pages).toBe('all');
expect(options.margins).toBe('default');
expect(options.pageRange).toBe('');
});
test('getOptions returns default values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
const options = preview.getOptions();
expect(options.paperSize).toBe('A4');
expect(options.orientation).toBe('portrait');
expect(options.scale).toBe(100);
expect(options.headers).toBe(true);
expect(options.background).toBe(true);
expect(options.pages).toBe('all');
expect(options.margins).toBe('default');
expect(options.pageRange).toBe('');
});
test('opens and closes', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(false);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('opens and closes', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
false
);
preview.close();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('updateScaleLabel reflects slider value', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
document.getElementById('print-scale').value = '75';
preview.updateScaleLabel();
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
});
test('updateScaleLabel reflects slider value', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
document.getElementById('print-scale').value = '75';
preview.updateScaleLabel();
expect(document.getElementById('print-scale-value').textContent).toBe('75%');
});
test('close button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-preview-close').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('close button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-preview-close').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('cancel button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-cancel').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(true);
});
test('cancel button closes preview', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Test</p>');
document.getElementById('print-cancel').click();
expect(document.getElementById('print-preview-overlay').classList.contains('hidden')).toBe(
true
);
});
test('getOptions reflects changed values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
test('getOptions reflects changed values', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
// Change paper size to Letter
const paperSelect = document.getElementById('print-paper-size');
paperSelect.value = 'Letter';
// Change paper size to Letter
const paperSelect = document.getElementById('print-paper-size');
paperSelect.value = 'Letter';
// Change scale
document.getElementById('print-scale').value = '50';
// Change scale
document.getElementById('print-scale').value = '50';
// Uncheck headers
document.getElementById('print-headers').checked = false;
// Uncheck headers
document.getElementById('print-headers').checked = false;
const options = preview.getOptions();
expect(options.paperSize).toBe('Letter');
expect(options.scale).toBe(50);
expect(options.headers).toBe(false);
});
const options = preview.getOptions();
expect(options.paperSize).toBe('Letter');
expect(options.scale).toBe(50);
expect(options.headers).toBe(false);
});
test('stores last content for refresh', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Hello World</p>');
expect(preview._lastContent).toBe('<p>Hello World</p>');
});
test('stores last content for refresh', () => {
const { PrintPreview } = require('../src/print-preview');
const preview = new PrintPreview();
preview.open('<p>Hello World</p>');
expect(preview._lastContent).toBe('<p>Hello World</p>');
});
});
+134
View File
@@ -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');
});
});
+21
View File
@@ -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}`);
});
});

Some files were not shown because too many files have changed in this diff Show More