mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4201acad9 | ||
|
|
0b5af04a6a | ||
|
|
2d6f909b0e | ||
|
|
8624257c1f | ||
|
|
b75f436aaa | ||
|
|
d81d8c28d2 | ||
|
|
559d09cad9 | ||
|
|
5ad582ec3e | ||
|
|
d02f896167 | ||
|
|
f34775ce53 | ||
|
|
5d6a55dd09 | ||
|
|
0e80e37d9d | ||
|
|
9a7e994224 | ||
|
|
3effacf816 | ||
|
|
1be10f9ae5 | ||
|
|
4c0b7d6e84 | ||
|
|
db04ea5854 | ||
|
|
567726022c | ||
|
|
1999a7716e | ||
|
|
16e071ae5e | ||
|
|
7ecf54ceb0 | ||
|
|
95e2f45f1f | ||
|
|
7fbbd60525 | ||
|
|
c715bb354c | ||
|
|
6525de7667 | ||
|
|
a2c1d0c11a | ||
|
|
e0d6315204 | ||
|
|
68ff7d076a | ||
|
|
67b57eae78 | ||
|
|
301c51ac84 | ||
|
|
904ffbdd5b | ||
|
|
ea383783ec | ||
|
|
e758a81c4c | ||
|
|
ea0f3f93bf | ||
|
|
188e73d7c1 | ||
|
|
8265e4ff88 | ||
|
|
8a8671b128 | ||
|
|
3646dc29c6 | ||
|
|
a999f598cc | ||
|
|
7a396f64a6 | ||
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 | ||
|
|
96df5652d6 | ||
|
|
a9e05d2c0f | ||
|
|
c982b3e90f | ||
|
|
cfaafc07b2 | ||
|
|
94ad99dc4d | ||
|
|
baf644d62b | ||
|
|
f9a5420ad2 | ||
|
|
f8361174f2 | ||
|
|
64df0660c8 | ||
|
|
bbfa2a38e9 | ||
|
|
94ec3f45ce | ||
|
|
a6747b12f0 | ||
|
|
7a641b2618 | ||
|
|
edefcb8409 | ||
|
|
5ee986fab8 | ||
|
|
c1573dba08 | ||
|
|
9576abc979 | ||
|
|
caa3f3d35a | ||
|
|
2a6f0fc302 | ||
|
|
4da5b7b9c4 | ||
|
|
1ba42592d7 | ||
|
|
bc6f1a7d41 | ||
|
|
539502d7ff | ||
|
|
b5771dd914 | ||
|
|
a816b6ec32 | ||
|
|
54c9484cb5 | ||
|
|
36e26318cd | ||
|
|
8abd580295 | ||
|
|
80294c9876 | ||
|
|
148b549c83 | ||
|
|
a3b4065984 |
@@ -19,17 +19,17 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install rpmbuild
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download external tools (pandoc)
|
||||||
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Build Linux packages
|
- name: Build Linux packages
|
||||||
run: npm run build:linux
|
run: npm run build:linux-ci -- --publish=never
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
- name: Upload Linux artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -56,14 +56,19 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download external tools (pandoc)
|
||||||
|
run: node scripts/download-tools.js
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
- name: Decode certificate (if available)
|
- name: Decode certificate (if available)
|
||||||
if: ${{ secrets.CSC_LINK_BASE64 != '' }}
|
if: ${{ env.CSC_LINK_BASE64 != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
CSC_LINK_BASE64: ${{ secrets.CSC_LINK_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
$bytes = [Convert]::FromBase64String("${{ secrets.CSC_LINK_BASE64 }}")
|
$bytes = [Convert]::FromBase64String("$env:CSC_LINK_BASE64")
|
||||||
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
|
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
|
||||||
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
|
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||||
|
|
||||||
@@ -72,13 +77,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CSC_LINK: code-signing-cert.pfx
|
CSC_LINK: code-signing-cert.pfx
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
run: npm run build:win-signed
|
run: npm run build:win-signed -- --publish=never
|
||||||
|
|
||||||
- name: Build Windows packages (unsigned)
|
- name: Build Windows packages (unsigned)
|
||||||
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
if: ${{ env.CERT_AVAILABLE != 'true' }}
|
||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||||
run: npm run build:win-unsigned
|
run: npm run build:win-unsigned -- --publish=never
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
- name: Upload Windows artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -86,24 +91,26 @@ jobs:
|
|||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: |
|
path: |
|
||||||
dist/*.exe
|
dist/*.exe
|
||||||
dist/*-win.zip
|
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows]
|
||||||
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
- name: Download Linux artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: linux-artifacts
|
name: linux-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- name: Download Windows artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: windows-artifacts
|
name: windows-artifacts
|
||||||
path: dist
|
path: dist
|
||||||
|
|||||||
+7
-1
@@ -16,7 +16,10 @@ out/
|
|||||||
.cache/
|
.cache/
|
||||||
.npm/
|
.npm/
|
||||||
.electron/
|
.electron/
|
||||||
package-lock.json
|
# package-lock.json is intentionally tracked for reproducible CI builds
|
||||||
|
|
||||||
|
# Downloaded tool binaries (fetched at build time via scripts/download-tools.js)
|
||||||
|
bin/
|
||||||
|
|
||||||
# Code signing certificates — never commit private keys
|
# Code signing certificates — never commit private keys
|
||||||
*.pfx
|
*.pfx
|
||||||
@@ -40,3 +43,6 @@ pdf\ modal.png
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
agents.md
|
agents.md
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Superpowers brainstorm artifacts
|
||||||
|
.superpowers/
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/renderer/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
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
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# MarkdownConverter — React + shadcn/ui UI Redesign
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Design (awaiting user approval)
|
||||||
|
**Branch:** `react-electron`
|
||||||
|
**Author:** Brainstormed with user via superpowers:brainstorming
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the legacy vanilla JS renderer (`src/renderer.js` is 213 KB; `src/styles.css` is 74 KB) with a modern React 19 + Vite + TypeScript + shadcn/ui renderer that achieves visual feature parity while delivering a Polished + Glassy (Raycast/Arc-style) aesthetic. The Electron main process, preload bridge, and IPC contracts stay unchanged. Work proceeds via vertical slices — each PR ships a demoable feature.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Replace every render-time feature of the legacy renderer with an idiomatic React + shadcn/ui implementation.
|
||||||
|
2. Adopt the "Polished + Glassy" visual language (subtle shadows, 8–12 px radii, gradient accents, gentle depth) on top of the existing ConcreteInfo brand tokens and design system.
|
||||||
|
3. Use a single component foundation (shadcn/ui) and a single motion library (Motion / Framer Motion) to keep the dependency surface narrow and the design coherent.
|
||||||
|
4. Keep the main process, preload, and IPC contracts untouched. The renderer is the only surface being rewritten.
|
||||||
|
5. Keep the app runnable at every commit. No "big bang" merge.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Migrating the main process to TypeScript (out of scope for this spec).
|
||||||
|
- Adding new features the legacy renderer does not have (plugin system, AI features, etc. — those are future specs).
|
||||||
|
- Replacing Pandoc/FFmpeg/ImageMagick orchestration.
|
||||||
|
- Changing the packaging pipeline (electron-builder config stays).
|
||||||
|
- Replacing the CodeMirror 6 editor — it is the right tool and is already wired up.
|
||||||
|
|
||||||
|
## Decisions Locked During Brainstorming
|
||||||
|
|
||||||
|
| Decision | Choice | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Scope | Full feature parity with legacy renderer | User-selected option. Renderer is one cohesive surface; splitting it across specs would force premature contracts. |
|
||||||
|
| Visual style | **B — Polished + Glassy** (Raycast/Arc aesthetic) | "Fancy" comes from elevation, gradient accents, and material depth — not over-designed visuals. |
|
||||||
|
| Layout | **2 — IDE-style** (equal editor/preview, draggable divider, collapsible sidebar) | Power-user markdown apps converge on this. Draggable divider + keyboard reset. |
|
||||||
|
| Modal patterns | **A (Centered Dialog), B (Right Side-Sheet), D (Toasts)** | No command palette. |
|
||||||
|
| Command palette | **No** — full menus, no ⌘K | Power users can use the OS-level launcher. App stays focused on writing. |
|
||||||
|
| Animation | **Motion (Framer Motion)** | Best for layout transitions, modal/drawer enter/exit, drag. Sparingly used. |
|
||||||
|
| Component library | **shadcn/ui** | Tailwind + Radix primitives, copy-paste ownership, perfect fit for the "glassy" aesthetic and the HSL-CSS-variable foundation that is already in place. |
|
||||||
|
| Implementation strategy | **Vertical slices** — one feature end-to-end per PR | App stays runnable. Each PR is reviewable. |
|
||||||
|
|
||||||
|
## Defaults (Used Unless Overridden Later)
|
||||||
|
|
||||||
|
| Item | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| State management | Zustand (already in deps) + Immer for nested patches | Already in `package.json`; perfect for editor state. |
|
||||||
|
| Theming | shadcn `next-themes`, dark default + light, system-aware | shadcn canonical pattern. Brand colors already in CSS vars. |
|
||||||
|
| Icons | `lucide-react` (already in deps) | 1000+ tree-shakable icons. |
|
||||||
|
| Forms | `react-hook-form` + `zod` | Standard for shadcn forms. |
|
||||||
|
| Drag/drop (sortable lists) | `@dnd-kit/core` | De facto React drag lib (file tree reordering, plugin list reorder). |
|
||||||
|
| Pane resize (split layout) | `react-resizable-panels` | Canonical React lib for resizable pane groups; handles drag, arrow keys, snap, persisted sizes. |
|
||||||
|
| Testing | Vitest + RTL + Playwright (E2E + visual regression) | Standard for React + Electron. |
|
||||||
|
| TypeScript | Strict mode (already wired) | Already in deps and `vite.renderer.config.ts`. |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Electron app keeps its existing process model. Only the renderer is rewritten.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron Main (UNCHANGED) │
|
||||||
|
│ - BrowserWindow, IPC handlers, file/fs ops │
|
||||||
|
│ - Pandoc, FFmpeg, ImageMagick orchestration │
|
||||||
|
└────────────┬─────────────────────────────────────────────┘
|
||||||
|
│ contextBridge (UNCHANGED preload.js)
|
||||||
|
┌────────────▼─────────────────────────────────────────────┐
|
||||||
|
│ React Renderer (REWRITTEN) │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AppShell (layout) │ │
|
||||||
|
│ │ ├─ MenuBar (native) │ │
|
||||||
|
│ │ ├─ AppHeader (logo, breadcrumbs, theme toggle) │ │
|
||||||
|
│ │ ├─ TabBar (open files) │ │
|
||||||
|
│ │ ├─ Toolbar (formatting) │ │
|
||||||
|
│ │ ├─ ResizablePaneGroup (sidebar | editor | preview) │ │
|
||||||
|
│ │ │ ├─ Sidebar (file tree, outline) │ │
|
||||||
|
│ │ │ ├─ EditorPane (CodeMirror 6) │ │
|
||||||
|
│ │ │ └─ PreviewPane (marked + KaTeX + Mermaid) │ │
|
||||||
|
│ │ ├─ StatusBar (word count, encoding, cursor pos) │ │
|
||||||
|
│ │ └─ ModalLayer (Dialog, SideSheet, Toaster) │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Feature Modules (each owns UI + state slice) │ │
|
||||||
|
│ │ ├─ editor/ CodeMirror wrapper, syntax, themes │ │
|
||||||
|
│ │ ├─ preview/ markdown→html, KaTeX, Mermaid │ │
|
||||||
|
│ │ ├─ tabs/ open files, dirty state │ │
|
||||||
|
│ │ ├─ sidebar/ file tree, outline, search results │ │
|
||||||
|
│ │ ├─ modals/ export, settings, about, etc. │ │
|
||||||
|
│ │ ├─ tools/ zen, repl, ascii-gen, table-gen │ │
|
||||||
|
│ │ └─ export/ pdf, docx, html, image batch │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Shared Infrastructure │ │
|
||||||
|
│ │ ├─ stores/ Zustand slices per feature │ │
|
||||||
|
│ │ ├─ hooks/ useFile, useEditor, useTheme, etc. │ │
|
||||||
|
│ │ ├─ lib/ cn, ipc, formatters, validators │ │
|
||||||
|
│ │ ├─ ui/ shadcn primitives: button, dialog… │ │
|
||||||
|
│ │ └─ types/ shared TS types │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architectural Rules
|
||||||
|
|
||||||
|
- **Shell components** (`AppHeader`, `TabBar`, `StatusBar`) subscribe only to `useAppStore`.
|
||||||
|
- **Feature components** subscribe to their own feature's store.
|
||||||
|
- **Cross-feature access** goes through hooks, not direct store imports (e.g., `useFileTree()` wraps `useFileStore`).
|
||||||
|
- **Feature modules are self-contained** — each owns its components, its Zustand slice, and its types. Safe to develop in parallel later.
|
||||||
|
- **No direct `window.electronAPI` calls** from feature code. All IPC goes through `lib/ipc.ts` for type safety and error normalization.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
```
|
||||||
|
stores/
|
||||||
|
├─ useAppStore // global UI: theme, sidebar, pane sizes, modals open
|
||||||
|
├─ useFileStore // file system: tree, open files, active tab
|
||||||
|
├─ useEditorStore // editor: per-file content, cursor, selection, dirty
|
||||||
|
├─ usePreviewStore // preview: scroll sync, zoom, theme
|
||||||
|
├─ useSettingsStore // user prefs (persisted to electron-store)
|
||||||
|
└─ useCommandStore // menu actions registry, recent actions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why slices instead of one mega-store?** Editor content can be megabytes. Keeping it in its own slice lets React avoid re-rendering the preview when only the editor changes — components subscribe with selectors.
|
||||||
|
|
||||||
|
**Persistent state** (auto-saved to electron-store via Zustand `persist` middleware): theme, sidebar visibility, last open files, pane divider positions, settings.
|
||||||
|
|
||||||
|
**Ephemeral state** (lost on quit): active modal, hover states, search query, current file content (saved to disk on idle).
|
||||||
|
|
||||||
|
## Visual Design System
|
||||||
|
|
||||||
|
### Color Tokens
|
||||||
|
|
||||||
|
The existing `globals.css` HSL variables stay. Additions for the "glassy" aesthetic:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
```
|
||||||
|
|
||||||
|
### shadcn Components
|
||||||
|
|
||||||
|
Install via `npx shadcn@latest add`:
|
||||||
|
|
||||||
|
**Primitives** (need all of these): `button`, `dialog`, `sheet`, `popover`, `tooltip`, `select`, `dropdown-menu`, `tabs`, `separator`, `scroll-area`, `toggle`, `switch`, `slider`, `input`, `textarea`, `label`, `form`, `skeleton`, `sonner`, `command`.
|
||||||
|
|
||||||
|
**Composite** (custom-built on top of primitives): `file-tree`, `pane-group`, `divider`, `status-bar`, `menu-bar`, `toast`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Body: Plus Jakarta Sans 15 px / line-height 1.6
|
||||||
|
- Code: JetBrains Mono 13.5 px
|
||||||
|
- Display: Barlow Condensed 700/800 — used sparingly (page titles, big metrics)
|
||||||
|
- Headings: Plus Jakarta Sans 600/700 with tight letter-spacing
|
||||||
|
- Labels (`.label`): Plus Jakarta Sans 500, 12 px, uppercase, 0.05 em letter-spacing
|
||||||
|
|
||||||
|
### Motion Choreography
|
||||||
|
|
||||||
|
| Element | Motion | Duration | Easing |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Modal (Dialog A) | Scale 0.96→1, opacity 0→1 | 200 ms | `ease-out` |
|
||||||
|
| Side sheet (B) | TranslateX 100%→0, opacity 0→1 | 300 ms | `cubic-bezier(0.16, 1, 0.3, 1)` |
|
||||||
|
| Toast | TranslateY 100%→0, opacity | 250 ms | spring(stiffness: 300, damping: 30) |
|
||||||
|
| Sidebar toggle | Width 264 px↔72 px, content fade | 250 ms | `ease-in-out` |
|
||||||
|
| Divider drag | Live width update | 0 ms | none (instant) |
|
||||||
|
| Theme switch | CSS variables, opacity overlay | 300 ms | `ease-in-out` |
|
||||||
|
| Tab switch | Underline slide | 200 ms | `ease-out` |
|
||||||
|
| Hover (buttons) | `bg` + `shadow` shift | 150 ms | `ease-out` |
|
||||||
|
| Focus ring | Outline grow | 100 ms | `ease-out` |
|
||||||
|
|
||||||
|
**Rules:** Every motion has a purpose. No gratuitous animation. Respects `prefers-reduced-motion` (Motion handles this automatically).
|
||||||
|
|
||||||
|
### Empty / Loading / Error States
|
||||||
|
|
||||||
|
Every async surface ships all three. Sonner toasts for ephemeral feedback. Skeleton screens for content areas. Empty-state components with icon + message + primary CTA.
|
||||||
|
|
||||||
|
## Modal/Overlay Patterns
|
||||||
|
|
||||||
|
| Use case | Pattern | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Export (PDF/DOCX/HTML) | A — Centered Dialog | Focused decision, ~3–5 fields, "do one thing" |
|
||||||
|
| Settings | B — Right Side-Sheet | Long form, tabbed sections, stays open while editing |
|
||||||
|
| Find/Replace | Inline toolbar (not modal) | Always-visible utility |
|
||||||
|
| About | A — Centered Dialog | Tiny info dump |
|
||||||
|
| Confirm destructive (delete, close unsaved) | A — Centered Dialog | Forces attention |
|
||||||
|
| File save error | D — Toast | Non-blocking info |
|
||||||
|
| Pandoc/FFmpeg progress | D — Toast → sticky on >3 s | Background work feedback |
|
||||||
|
| Plugin manager | B — Right Side-Sheet | Lists + per-item actions |
|
||||||
|
| ASCII/Table generators | A — Centered Dialog | Tool-style focused input → output |
|
||||||
|
| Print preview | A — Centered Dialog | Full-screen-ish modal overlay |
|
||||||
|
| Welcome (first launch) | A — Centered Dialog | One-time onboarding |
|
||||||
|
| Word export | A — Centered Dialog | Template selection |
|
||||||
|
| Zen mode | Full-viewport toggle (no modal) | Replaces the editor entirely |
|
||||||
|
| REPL | Bottom-pinned panel (split-pane) | Persistent terminal-like UI |
|
||||||
|
| Quick file open | B — Right Side-Sheet | File tree, search, recent |
|
||||||
|
|
||||||
|
### Dialog A Anatomy (Export as example)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Backdrop (rgba(13,11,9,0.45) + backdrop-blur 4px) ─────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌──── Modal (max-w-md, rounded-2xl, shadow-lg) ─────┐ │
|
||||||
|
│ │ ┌── Header ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Icon] Export as PDF [×] │ │ │
|
||||||
|
│ │ │ Choose format options │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Body ──────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Format: [Letter] [A4] [Legal] │ │ │
|
||||||
|
│ │ │ Margins: ────●──── │ │ │
|
||||||
|
│ │ │ ☐ Include table of contents │ │ │
|
||||||
|
│ │ │ ☐ Embed fonts │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ┌── Footer ────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [Cancel] [Export →] │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Side-Sheet B Anatomy (Settings as example)
|
||||||
|
|
||||||
|
Slides in from right, ~50 % width (max 560 px). Backdrop is `rgba(13,11,9,0.2)` with `backdrop-blur(2 px)` (lighter than dialog — the sheet is the focus). Tab navigation in the sheet header (Editor / Theme / Export / Plugins / About).
|
||||||
|
|
||||||
|
### Toast D Anatomy (Sonner)
|
||||||
|
|
||||||
|
Stacked bottom-right, max 3 visible. Glass background. Color-coded 3 px left border — success `#1a7a56`, error `#ef4444`, info `#0ea5e9`, warning `#eab308`. Icon + title + description. Optional action button. Auto-dismiss 4 s for success, sticky for error.
|
||||||
|
|
||||||
|
## Data Flow (Editor → Preview Pipeline)
|
||||||
|
|
||||||
|
```
|
||||||
|
User types
|
||||||
|
↓
|
||||||
|
CodeMirror onChange
|
||||||
|
↓
|
||||||
|
useEditorStore.updateContent(tabId, content) ← debounced 50 ms
|
||||||
|
↓
|
||||||
|
┌─ Persist to electron-store on idle (1 s) ─┐
|
||||||
|
│ │
|
||||||
|
└─ Publish to usePreviewStore (subscribed) ─┘
|
||||||
|
↓
|
||||||
|
marked(content) → sanitized HTML
|
||||||
|
↓
|
||||||
|
PreviewPane renders HTML
|
||||||
|
KaTeX post-processes $...$
|
||||||
|
Mermaid post-processes ```mermaid
|
||||||
|
Highlight.js post-processes ```lang
|
||||||
|
↓
|
||||||
|
useEffect: scroll-sync editor cursor → preview position
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bidirectional scroll sync:** editor scroll → preview (primary), preview click → editor cursor (secondary). Throttled to 60 fps via `requestAnimationFrame`.
|
||||||
|
|
||||||
|
## IPC Contract
|
||||||
|
|
||||||
|
The preload bridge stays unchanged. A new `src/renderer/lib/ipc.ts` wraps every channel with TypeScript types and normalizes errors to a discriminated union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult>>,
|
||||||
|
read: (path: string): Promise<IpcResult<string>>,
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void>>,
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[]>>,
|
||||||
|
onChange: (cb: (path: string) => void) => () => void,
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult>>,
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult>>,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string>>,
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void>>,
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void>>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Layered defense — no single failure can crash the app:
|
||||||
|
|
||||||
|
1. **IPC errors** → caught in `lib/ipc.ts` wrapper, returned as `IpcResult<T>` discriminated union. Components show toast on error.
|
||||||
|
2. **Component errors** → React error boundary per feature area (one for editor, one for preview, one for modals). On error: show inline error UI with "Reload this panel" + "Copy details" actions.
|
||||||
|
3. **Async operations** (export, file ops) → loading state on button + toast on completion. Long ops (>2 s) get a sticky toast with cancel.
|
||||||
|
4. **Validation errors** (settings, export options) → inline form errors via `react-hook-form` + `zod`. shadcn `Form` component for consistent error display.
|
||||||
|
5. **Pandoc/FFmpeg missing** → detected at startup, banner + disable export menu items. Don't surprise-fail at export time.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit (Vitest):** stores, hooks, lib utilities, formatters, validators
|
||||||
|
- **Component (Vitest + RTL):** shadcn wrappers, dialog interactions, sidebar toggle, divider drag, theme toggle
|
||||||
|
- **Integration (Vitest + RTL):** editor ↔ preview flow, file open → tab add → content display, settings change persists
|
||||||
|
- **E2E (Playwright):** app launches, file opens, markdown renders, export to PDF works
|
||||||
|
- **Visual regression (Playwright snapshots):** locked-in screenshots for header, sidebar, dialogs, toasts — catch accidental style drift
|
||||||
|
- **Target coverage:** stores/hooks/lib ≥ 90 %, components ≥ 75 %, E2E covers all critical paths
|
||||||
|
|
||||||
|
## Accessibility (WCAG 2.1 AA)
|
||||||
|
|
||||||
|
Baked in via shadcn + Radix:
|
||||||
|
|
||||||
|
- All dialogs focus-trap, Esc to close, return focus to trigger
|
||||||
|
- All interactive elements keyboard-reachable
|
||||||
|
- Visible focus rings (2 px brand ring)
|
||||||
|
- 4.5:1 contrast minimum (already met by brand colors)
|
||||||
|
- `aria-label` on icon buttons, `role="alert"` on toasts
|
||||||
|
- Respects `prefers-reduced-motion` (Motion handles this automatically)
|
||||||
|
|
||||||
|
## Implementation Phases (Vertical Slices)
|
||||||
|
|
||||||
|
Each phase is a shippable PR that keeps the app runnable. The legacy `renderer.js` is gradually replaced; until each phase is complete, the old renderer code still runs the missing parts.
|
||||||
|
|
||||||
|
| # | Phase | Output | Acceptance |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Foundation** | shadcn installed; `next-themes`, `motion`, `react-hook-form`, `zod`, `@dnd-kit/core`, `react-resizable-panels`, `sonner` installed; design tokens finalized; `lib/utils.ts` (`cn`), `lib/ipc.ts` typed wrappers, `lib/motion.ts` preset transitions, `App.tsx` shell skeleton renders | `npm run build` succeeds; dev server shows the new shell with theme toggle working |
|
||||||
|
| 2 | **App shell + layout** | `AppHeader`, `TabBar`, `Toolbar`, `Breadcrumb`, `StatusBar`, `ResizablePaneGroup` with sidebar toggle and draggable divider. Empty editor/preview panes. | Resize divider with mouse and arrow keys; sidebar collapses; pane sizes persist. |
|
||||||
|
| 3 | **Editor pane** | CodeMirror 6 wrapped, dark/light themes wired to shadcn theme, syntax highlighting, line numbers, search, autocomplete | Open `.md` file → renders in editor; can edit and save. |
|
||||||
|
| 4 | **Preview pane** | marked + DOMPurify + KaTeX + Mermaid + highlight.js. Bidirectional scroll sync with editor. | Open file → preview renders, follows cursor. |
|
||||||
|
| 5 | **File tree + tabs** | Sidebar file tree (read directory on demand, lazy-expand children only on click). Tabs for open files. Dirty state indicator. File content is read from disk only when the tab is activated. | Open folder → root populates; click folder → children load; click file → opens in tab; close tab reverts dirty. |
|
||||||
|
| 6 | **Native menus + toolbar** | Replace the legacy `CommandPalette` with full menus (File / Edit / View / Insert / Format / Tools / Help) bound to keyboard shortcuts. Toolbar buttons. | All menu items invoke the right action; shortcuts work; toolbar reflects active state. |
|
||||||
|
| 7 | **Modals** | Export dialog (PDF/DOCX/HTML/batch), Settings side-sheet (Editor/Theme/Export/Plugins/About tabs), About dialog, Confirm-destructive dialog | All dialogs and sheet render with proper motion; settings persist; export works end-to-end. |
|
||||||
|
| 8 | **Toasts** | Sonner wired into all async operations (save, export, errors, tool missing) | All operations give appropriate feedback; no silent failures. |
|
||||||
|
| 9 | **Advanced tools** | Zen mode (full-viewport toggle), REPL (bottom-pinned panel), ASCII generator, Table generator, Word export template picker, Print preview | Each tool is feature-complete vs. legacy version. |
|
||||||
|
| 10 | **Polish + delete legacy** | Visual regression snapshots locked; remove `renderer.js` and old `styles*.css` references from build; `npm run build:linux` produces a working installer | Final PR ships an installer with no legacy code paths. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| shadcn CLI requires Vite (not vanilla Electron renderer) | Vite is already configured for the renderer in `vite.renderer.config.ts`; the CLI works against the same project. |
|
||||||
|
| shadcn CLI assumes Tailwind is at project root; we have it in renderer/ only | Point the CLI at `src/renderer/components.json` and set `tailwind.css` to the right path. |
|
||||||
|
| Marked + KaTeX + Mermaid + highlight.js together is heavy | Lazy-load Mermaid (only when `mermaid` code block encountered). KaTeX is loaded once, cached. |
|
||||||
|
| Editor content state can be megabytes → re-render storms | Editor content lives in its own slice; preview subscribes to a derived/cached HTML string; components use `useShallow` / selector subscriptions. |
|
||||||
|
| Bidirectional scroll sync loops | `useEffect` debounce + ignore if cursor/selection is the sync source. |
|
||||||
|
| CodeMirror 6 doesn't ship a Tailwind theme by default | Use `@codemirror/theme-one-dark` for dark; build a custom theme that pulls from CSS variables for light. |
|
||||||
|
| The legacy `renderer.js` and `styles*.css` are referenced from `index.html` (now `src/renderer/index.html`) | Phase 10 deletes the references; until then, both run side-by-side and the new React app sits in a known root div. |
|
||||||
|
| Electron `nodeIntegration` is off; we use contextBridge | Already the case. Document the contract clearly in `lib/ipc.ts`. |
|
||||||
|
|
||||||
|
## Open Questions (to confirm before implementation)
|
||||||
|
|
||||||
|
None. All major decisions are locked. Defaults will be used unless the user overrides them when reviewing the implementation plan.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `src/renderer.js` (213 KB) — legacy renderer being replaced
|
||||||
|
- `src/styles.css` (74 KB), `src/styles-modern.css` (71 KB), `src/styles-concreteinfo.css` (21 KB) — legacy styles being replaced
|
||||||
|
- `src/renderer/styles/globals.css` — already shadcn-compatible (HSL CSS variables)
|
||||||
|
- `src/renderer/App.tsx` — empty skeleton that references components we will build
|
||||||
|
- `tailwind.config.js` — brand colors and fonts already defined
|
||||||
|
- `vite.renderer.config.ts` — Vite + React plugin already configured
|
||||||
|
- `package.json` — CodeMirror 6, TanStack Table, lucide-react, cva, clsx, tailwind-merge, tailwindcss-animate already installed
|
||||||
@@ -75,6 +75,7 @@ module.exports = [
|
|||||||
jest: 'readonly',
|
jest: 'readonly',
|
||||||
describe: 'readonly',
|
describe: 'readonly',
|
||||||
test: 'readonly',
|
test: 'readonly',
|
||||||
|
it: 'readonly',
|
||||||
expect: 'readonly',
|
expect: 'readonly',
|
||||||
beforeEach: 'readonly',
|
beforeEach: 'readonly',
|
||||||
afterEach: 'readonly',
|
afterEach: 'readonly',
|
||||||
|
|||||||
Generated
+16637
File diff suppressed because it is too large
Load Diff
+67
-18
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "markdown-converter",
|
"name": "markdown-converter",
|
||||||
"version": "4.3.0",
|
"version": "4.4.2",
|
||||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
|
"test:renderer": "vitest run",
|
||||||
|
"test:renderer:watch": "vitest",
|
||||||
|
"test:renderer:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src tests",
|
"lint": "eslint src tests",
|
||||||
"lint:fix": "eslint src tests --fix",
|
"lint:fix": "eslint src tests --fix",
|
||||||
"format": "prettier --write src tests",
|
"format": "prettier --write src tests",
|
||||||
@@ -19,9 +22,11 @@
|
|||||||
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
|
||||||
"build:mac": "electron-builder --mac",
|
"build:mac": "electron-builder --mac",
|
||||||
"build:linux": "electron-builder --linux",
|
"build:linux": "electron-builder --linux",
|
||||||
|
"build:linux-ci": "electron-builder --linux deb AppImage",
|
||||||
"build:local": "electron-builder --linux --win",
|
"build:local": "electron-builder --linux --win",
|
||||||
"dist": "electron-builder --publish=never",
|
"dist": "electron-builder --publish=never",
|
||||||
"dist:all": "electron-builder -mwl",
|
"dist:all": "electron-builder -mwl",
|
||||||
|
"download-tools": "node scripts/download-tools.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js"
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -41,7 +46,19 @@
|
|||||||
"url": "https://github.com/amitwh/markdown-converter"
|
"url": "https://github.com/amitwh/markdown-converter"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"@vitest/ui": "^4.1.8",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
@@ -50,8 +67,17 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
@@ -68,24 +94,41 @@
|
|||||||
"@codemirror/state": "^6.5.4",
|
"@codemirror/state": "^6.5.4",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.16",
|
"@codemirror/view": "^6.39.16",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"core-util-is": "^1.0.3",
|
"core-util-is": "^1.0.3",
|
||||||
"docx": "^9.6.0",
|
"docx": "^9.6.0",
|
||||||
"docx4js": "^3.3.0",
|
"docx4js": "^2.0.1",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"electron-store": "^10.1.0",
|
"electron-store": "^10.1.0",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2pdf.js": "^0.14.0",
|
"html2pdf.js": "^0.14.0",
|
||||||
|
"immer": "^11.1.8",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"marked-footnote": "^1.4.0",
|
"marked-footnote": "^1.4.0",
|
||||||
"marked-highlight": "^2.2.3",
|
"marked-highlight": "^2.2.3",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
"motion": "^12.40.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.5.207",
|
"pdfjs-dist": "^5.5.207",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"pizzip": "^3.2.0",
|
"pizzip": "^3.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
|
"react-resizable-panels": "^4.11.2",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
"tslib": "^2.8.1"
|
"sonner": "^2.0.7",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -109,6 +152,10 @@
|
|||||||
"node_modules/**/*",
|
"node_modules/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/ffmpeg-static/**"
|
||||||
|
],
|
||||||
|
"extraFiles": [],
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
"ext": "md",
|
"ext": "md",
|
||||||
@@ -160,7 +207,14 @@
|
|||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
"requestedExecutionLevel": "asInvoker",
|
"requestedExecutionLevel": "asInvoker",
|
||||||
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
"legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
|
||||||
"verifyUpdateCodeSignature": false
|
"verifyUpdateCodeSignature": false,
|
||||||
|
"signAndEditExecutable": false,
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "bin/win32/pandoc.exe",
|
||||||
|
"to": "bin/pandoc.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -182,29 +236,24 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"deb",
|
"deb",
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"snap",
|
"snap"
|
||||||
"rpm"
|
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "bin/linux/pandoc",
|
||||||
|
"to": "bin/pandoc"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": [
|
"depends": [
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"imagemagick",
|
"imagemagick",
|
||||||
"libreoffice-common"
|
"libreoffice-common"
|
||||||
],
|
],
|
||||||
"description": "Professional Markdown editor and universal file converter",
|
"description": "Professional Markdown editor and universal file converter",
|
||||||
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
"maintainer": "ConcreteInfo <amit.wh@gmail.com>"
|
||||||
},
|
|
||||||
"rpm": {
|
|
||||||
"depends": [
|
|
||||||
"pandoc",
|
|
||||||
"ffmpeg",
|
|
||||||
"ImageMagick",
|
|
||||||
"libreoffice-core"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Implements file system operations for Electron using IPC.
|
* Implements file system operations for Electron using IPC.
|
||||||
* This abstracts file operations to enable easier testing and migration.
|
* This abstracts file operations to enable easier testing and migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Adapters abstract file system, conversion, and system operations
|
* Adapters abstract file system, conversion, and system operations
|
||||||
* to enable easier testing and future platform migration.
|
* to enable easier testing and future platform migration.
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const jetBrainsMonoTheme = EditorView.theme({
|
|||||||
* @returns {EditorView} the created editor view
|
* @returns {EditorView} the created editor view
|
||||||
*/
|
*/
|
||||||
function createEditor(parentElement, options = {}) {
|
function createEditor(parentElement, options = {}) {
|
||||||
|
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight);
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error('[createEditor] ERROR: parentElement is null or undefined!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
content = '',
|
content = '',
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
|
|||||||
+59
-2
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
||||||
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://www.plantuml.com;">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MarkdownConverter</title>
|
<title>MarkdownConverter</title>
|
||||||
<!-- Design tokens - loaded first for CSS variable availability -->
|
<!-- Design tokens - loaded first for CSS variable availability -->
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<span class="app-title">MarkdownConverter</span>
|
<span class="app-title">MarkdownConverter</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-right">
|
<div class="app-header-right">
|
||||||
<span class="app-version">v4.2.0</span>
|
<span class="app-version" id="app-version-display"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
<div class="tab-bar" id="tab-bar" role="tablist" aria-label="Document tabs">
|
||||||
@@ -288,6 +288,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="export-section revealjs-only" style="display: none;">
|
||||||
|
<label>Reveal.js Options:</label>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-theme">Slide Theme:</label>
|
||||||
|
<select id="reveal-theme">
|
||||||
|
<option value="black">Black</option>
|
||||||
|
<option value="white">White</option>
|
||||||
|
<option value="league">League</option>
|
||||||
|
<option value="beige">Beige</option>
|
||||||
|
<option value="sky">Sky</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
<option value="serif">Serif</option>
|
||||||
|
<option value="simple">Simple</option>
|
||||||
|
<option value="solarized">Solarized</option>
|
||||||
|
<option value="blood">Blood</option>
|
||||||
|
<option value="moon">Moon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-transition">Transition:</label>
|
||||||
|
<select id="reveal-transition">
|
||||||
|
<option value="slide">Slide</option>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="fade">Fade</option>
|
||||||
|
<option value="convex">Convex</option>
|
||||||
|
<option value="concave">Concave</option>
|
||||||
|
<option value="zoom">Zoom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="reveal-speed">Transition Speed:</label>
|
||||||
|
<select id="reveal-speed">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="fast">Fast</option>
|
||||||
|
<option value="slow">Slow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label><input type="checkbox" id="reveal-slide-number"> Slide Numbers</label>
|
||||||
|
<label><input type="checkbox" id="reveal-controls" checked> Controls</label>
|
||||||
|
<label><input type="checkbox" id="reveal-progress" checked> Progress Bar</label>
|
||||||
|
<label><input type="checkbox" id="reveal-history" checked> Slide History</label>
|
||||||
|
<label><input type="checkbox" id="reveal-center" checked> Center Content</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
<label>Bibliography:</label>
|
<label>Bibliography:</label>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -905,6 +951,7 @@
|
|||||||
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
<button class="modal-close" id="pdf-editor-dialog-close" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body pdf-editor-body">
|
<div class="modal-body pdf-editor-body">
|
||||||
|
<div class="pdf-editor-left-panel">
|
||||||
<!-- Merge PDFs Section -->
|
<!-- Merge PDFs Section -->
|
||||||
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
<div id="pdf-merge-section" class="pdf-operation-section hidden">
|
||||||
<div class="export-section">
|
<div class="export-section">
|
||||||
@@ -1287,6 +1334,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Panel: Scrollable Visual Page Sidebar -->
|
||||||
|
<div id="pdf-thumbnail-sidebar" class="pdf-editor-right-panel hidden">
|
||||||
|
<h4>Page Preview & Editor</h4>
|
||||||
|
<p class="sidebar-help">Select a PDF to view pages, check to delete, click ↻ to rotate, and use ◀ / ▶ to reorder.</p>
|
||||||
|
<div id="pdf-thumbnail-grid" class="pdf-thumbnail-grid">
|
||||||
|
<!-- Page thumbnail cards will be rendered here dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
<button id="pdf-editor-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||||
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
<button id="pdf-editor-process" class="btn btn-primary">Process</button>
|
||||||
|
|||||||
+150
-513
@@ -2,8 +2,9 @@ const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron')
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { exec, execFile, spawn } = require('child_process');
|
const { exec, execFile, spawn } = require('child_process');
|
||||||
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
|
||||||
const WordTemplateExporter = require('./wordTemplateExporter');
|
const WordTemplateExporter = require('./wordTemplateExporter');
|
||||||
|
const PDFOperations = require('./main/PDFOperations');
|
||||||
|
const GitOperations = require('./main/GitOperations');
|
||||||
|
|
||||||
// Add MiKTeX to PATH for LaTeX support
|
// Add MiKTeX to PATH for LaTeX support
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -14,13 +15,39 @@ if (process.platform === 'win32') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the system Pandoc path
|
// Returns path to pandoc: bundled binary when packaged, dev bin or system fallback otherwise.
|
||||||
function getPandocPath() {
|
function getPandocPath() {
|
||||||
// Pandoc is expected to be in the system's PATH.
|
if (app.isPackaged) {
|
||||||
// The command will be executed directly. Quoting is handled by exec.
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
||||||
|
return path.join(process.resourcesPath, 'bin', `pandoc${ext}`);
|
||||||
|
}
|
||||||
|
// Development: prefer locally-downloaded binary in bin/<platform>/
|
||||||
|
const devBin = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'bin',
|
||||||
|
process.platform,
|
||||||
|
process.platform === 'win32' ? 'pandoc.exe' : 'pandoc',
|
||||||
|
);
|
||||||
|
if (fs.existsSync(devBin)) return devBin;
|
||||||
return 'pandoc';
|
return 'pandoc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns path to ffmpeg: asar-unpacked bundled binary when packaged, system fallback otherwise.
|
||||||
|
function getFFmpegPath() {
|
||||||
|
try {
|
||||||
|
let ffmpegPath = require('ffmpeg-static');
|
||||||
|
if (app.isPackaged) {
|
||||||
|
// ffmpeg-static is in asarUnpack — rewrite the path to the unpacked location
|
||||||
|
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||||||
|
} catch (_) {
|
||||||
|
/* ffmpeg-static not available */
|
||||||
|
}
|
||||||
|
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||||||
|
}
|
||||||
|
|
||||||
// Check if Pandoc is available
|
// Check if Pandoc is available
|
||||||
function checkPandocAvailable() {
|
function checkPandocAvailable() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -215,7 +242,9 @@ function runPandoc(args, callback) {
|
|||||||
function runPandocCmd(cmdString, callback) {
|
function runPandocCmd(cmdString, callback) {
|
||||||
const parsed = parseCommand(cmdString);
|
const parsed = parseCommand(cmdString);
|
||||||
// Skip 'pandoc' if it's the first element (command itself)
|
// Skip 'pandoc' if it's the first element (command itself)
|
||||||
const args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
// Use path.basename to handle full paths like bin/linux/pandoc
|
||||||
|
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||||||
|
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||||||
const pandocPath = getPandocPath();
|
const pandocPath = getPandocPath();
|
||||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||||
}
|
}
|
||||||
@@ -283,6 +312,41 @@ const store = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plugin settings IPC handlers
|
||||||
|
ipcMain.handle('plugin-settings:get', (_event, key) => {
|
||||||
|
return store.get(key);
|
||||||
|
});
|
||||||
|
ipcMain.handle('plugin-settings:set', (_event, { key, value }) => {
|
||||||
|
store.set(key, value);
|
||||||
|
});
|
||||||
|
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SECURITY: Permission Request Handler
|
||||||
|
// ============================================
|
||||||
|
// Restrict permissions to only those explicitly needed.
|
||||||
|
// Deny: camera, microphone, geolocation, notifications, etc.
|
||||||
|
// Allow: clipboard-read, clipboard-write (user expects these)
|
||||||
|
app.on('web-contents-created', (event, contents) => {
|
||||||
|
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||||
|
const ALLOWED_PERMISSIONS = [
|
||||||
|
'clipboard-read',
|
||||||
|
'clipboard-write'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ALLOWED_PERMISSIONS.includes(permission)) {
|
||||||
|
callback(true);
|
||||||
|
} else {
|
||||||
|
// Deny all other permissions (camera, microphone, geolocation, etc.)
|
||||||
|
console.warn(`[Security] Blocked permission request: ${permission}`);
|
||||||
|
callback(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable file download dialogs for security
|
||||||
|
// contents.session.setDownloadPath(userDownloadsPath);
|
||||||
|
});
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let currentFile = null; // This will now represent the active tab's file
|
let currentFile = null; // This will now represent the active tab's file
|
||||||
let pandocAvailable = null; // Cache pandoc availability check
|
let pandocAvailable = null; // Cache pandoc availability check
|
||||||
@@ -787,6 +851,20 @@ function createMenu() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Custom Preview CSS',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Load Custom Preview CSS...',
|
||||||
|
click: () => mainWindow.webContents.send('load-custom-css')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Clear Custom Preview CSS',
|
||||||
|
click: () => mainWindow.webContents.send('clear-custom-css')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
||||||
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -1029,7 +1107,7 @@ function showAboutDialog() {
|
|||||||
<body>
|
<body>
|
||||||
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
<img src="${iconBase64}" class="logo" alt="MarkdownConverter">
|
||||||
<h1>MarkdownConverter</h1>
|
<h1>MarkdownConverter</h1>
|
||||||
<div class="version">Version 4.1.0</div>
|
<div class="version">Version ${app.getVersion()}</div>
|
||||||
|
|
||||||
<div class="company">
|
<div class="company">
|
||||||
<span>by</span>
|
<span>by</span>
|
||||||
@@ -1833,9 +1911,12 @@ function checkConverterAvailable(tool) {
|
|||||||
case 'imagemagick':
|
case 'imagemagick':
|
||||||
toolName = isWin ? 'magick' : 'convert';
|
toolName = isWin ? 'magick' : 'convert';
|
||||||
break;
|
break;
|
||||||
case 'ffmpeg':
|
case 'ffmpeg': {
|
||||||
toolName = 'ffmpeg';
|
// ffmpeg-static is always bundled — check it directly
|
||||||
break;
|
const ffmpegBin = getFFmpegPath();
|
||||||
|
resolve(fs.existsSync(ffmpegBin) || ffmpegBin === 'ffmpeg');
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
@@ -2062,8 +2143,8 @@ function convertWithImageMagick(inputFile, outputPath) {
|
|||||||
// FFmpeg conversion - returns {command, args} for execFile
|
// FFmpeg conversion - returns {command, args} for execFile
|
||||||
function convertWithFFmpeg(inputFile, outputPath) {
|
function convertWithFFmpeg(inputFile, outputPath) {
|
||||||
return {
|
return {
|
||||||
command: 'ffmpeg',
|
command: getFFmpegPath(),
|
||||||
args: ['-i', inputFile, outputPath, '-y']
|
args: ['-i', inputFile, outputPath, '-y'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2232,8 +2313,32 @@ function performExportWithOptions(format, options) {
|
|||||||
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
dialog.showErrorBox('Export Error', sanitizeErrorMessage(`Failed to export: ${err.message}`));
|
||||||
}
|
}
|
||||||
} else if (format === 'revealjs') {
|
} else if (format === 'revealjs') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
let revealCmd = `${getPandocPath()} "${currentFile}" -t revealjs -s -o "${outputFile}" --slide-level=2`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
if (options) {
|
||||||
|
if (options.revealTheme) revealCmd += ` -V theme="${options.revealTheme}"`;
|
||||||
|
if (options.revealTransition) revealCmd += ` -V transition="${options.revealTransition}"`;
|
||||||
|
if (options.revealTransitionSpeed) revealCmd += ` -V transitionSpeed="${options.revealTransitionSpeed}"`;
|
||||||
|
if (options.revealControls !== undefined) revealCmd += ` -V controls="${options.revealControls}"`;
|
||||||
|
if (options.revealSlideNumber !== undefined) revealCmd += ` -V slideNumber="${options.revealSlideNumber}"`;
|
||||||
|
if (options.revealProgress !== undefined) revealCmd += ` -V progress="${options.revealProgress}"`;
|
||||||
|
if (options.revealHistory !== undefined) revealCmd += ` -V history="${options.revealHistory}"`;
|
||||||
|
if (options.revealCenter !== undefined) revealCmd += ` -V center="${options.revealCenter}"`;
|
||||||
|
|
||||||
|
// Support for templates, metadata, bibliography
|
||||||
|
if (options.template && options.template !== 'default') {
|
||||||
|
revealCmd += ` --template="${options.template}"`;
|
||||||
|
}
|
||||||
|
if (options.metadata) {
|
||||||
|
for (const [key, value] of Object.entries(options.metadata)) {
|
||||||
|
if (value.trim()) {
|
||||||
|
revealCmd += ` -M ${key}="${value.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.bibliography) revealCmd += ` --bibliography="${options.bibliography}"`;
|
||||||
|
if (options.csl) revealCmd += ` --csl="${options.csl}"`;
|
||||||
|
}
|
||||||
|
exportWithPandoc(revealCmd, outputFile, format);
|
||||||
} else if (format === 'beamer') {
|
} else if (format === 'beamer') {
|
||||||
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
pandocCmd = `${getPandocPath()} "${currentFile}" -t beamer -o "${outputFile}"`;
|
||||||
exportWithPandoc(pandocCmd, outputFile, format);
|
exportWithPandoc(pandocCmd, outputFile, format);
|
||||||
@@ -3694,7 +3799,7 @@ app.on('open-file', (event, filePath) => {
|
|||||||
// Handle file opening from command line or file association
|
// Handle file opening from command line or file association
|
||||||
function openFileFromPath(filePath) {
|
function openFileFromPath(filePath) {
|
||||||
console.log('[MAIN] openFileFromPath called with:', filePath);
|
console.log('[MAIN] openFileFromPath called with:', filePath);
|
||||||
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow);
|
console.log('[MAIN] rendererReady:', rendererReady, 'mainWindow exists:', !!mainWindow, 'webContents exists:', !!(mainWindow?.webContents));
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
if (stats.size > MAX_FILE_SIZE) {
|
if (stats.size > MAX_FILE_SIZE) {
|
||||||
@@ -3704,6 +3809,7 @@ function openFileFromPath(filePath) {
|
|||||||
currentFile = filePath;
|
currentFile = filePath;
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
console.log('[MAIN] File read successfully, content length:', content.length);
|
console.log('[MAIN] File read successfully, content length:', content.length);
|
||||||
|
console.log('[MAIN] About to send file-opened. mainWindow:', !!mainWindow, 'webContents:', !!(mainWindow?.webContents), 'rendererReady:', rendererReady);
|
||||||
if (mainWindow && mainWindow.webContents && rendererReady) {
|
if (mainWindow && mainWindow.webContents && rendererReady) {
|
||||||
// Send file immediately - renderer-ready means UI is initialized
|
// Send file immediately - renderer-ready means UI is initialized
|
||||||
console.log('[MAIN] Sending file-opened to renderer');
|
console.log('[MAIN] Sending file-opened to renderer');
|
||||||
@@ -3719,433 +3825,9 @@ function openFileFromPath(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// PDF EDITOR OPERATIONS (using pdf-lib)
|
// PDF OPERATIONS — delegates to main/PDFOperations.js
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Helper function to parse page ranges (e.g., "1-5, 7, 9-12")
|
|
||||||
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)) { // Convert to 0-indexed
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to convert hex color to RGB
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Merge Operation
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Split Operation
|
|
||||||
async function pdfSplit(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
let splits = [];
|
|
||||||
|
|
||||||
if (data.splitMode === 'pages') {
|
|
||||||
// Split by page ranges
|
|
||||||
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
|
||||||
const range = ranges[i];
|
|
||||||
let 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') {
|
|
||||||
// Split every N pages
|
|
||||||
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') {
|
|
||||||
// Split by size (approximate - we'll split evenly)
|
|
||||||
// This is complex, so we'll do a simple even split for now
|
|
||||||
const maxSize = data.maxSize * 1024 * 1024; // Convert MB to bytes
|
|
||||||
// For simplicity, split into fixed page chunks
|
|
||||||
const chunkSize = Math.max(1, Math.floor(totalPages / 5)); // Split into ~5 parts
|
|
||||||
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}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create split PDFs
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Compress Operation
|
|
||||||
async function pdfCompress(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
|
|
||||||
// pdf-lib doesn't have built-in compression, but we can save with default compression
|
|
||||||
// For actual compression, we would need additional libraries like Ghostscript
|
|
||||||
// For now, we'll save with standard options which provides some compression
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Rotate Pages Operation
|
|
||||||
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 {
|
|
||||||
// Rotate all pages
|
|
||||||
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}°`
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Delete Pages Operation
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Remove pages in reverse order to maintain indices
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Reorder Pages Operation
|
|
||||||
async function pdfReorder(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
// Parse new page order
|
|
||||||
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); // Convert to 0-indexed
|
|
||||||
|
|
||||||
// Validate new order
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Watermark Operation
|
|
||||||
async function pdfWatermark(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const totalPages = pdf.getPageCount();
|
|
||||||
|
|
||||||
// Determine which pages to watermark
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Calculate position based on selected position
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Encrypt (Password Protection) Operation
|
|
||||||
async function pdfEncrypt(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
|
|
||||||
// pdf-lib has limited encryption support in v1.17.1
|
|
||||||
// We'll save with password (basic encryption)
|
|
||||||
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 pdf-lib doesn't support encryption in this version, provide a helpful 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Decrypt (Remove Password) Operation
|
|
||||||
async function pdfDecrypt(data) {
|
|
||||||
try {
|
|
||||||
const pdfBytes = fs.readFileSync(data.inputPath);
|
|
||||||
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
|
||||||
|
|
||||||
// Save without 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PDF Permissions Operation
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPC Handler for PDF Operations
|
|
||||||
ipcMain.on('process-pdf-operation', async (event, data) => {
|
ipcMain.on('process-pdf-operation', async (event, data) => {
|
||||||
try {
|
try {
|
||||||
mainWindow.webContents.send('pdf-operation-progress', {
|
mainWindow.webContents.send('pdf-operation-progress', {
|
||||||
@@ -4153,43 +3835,7 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
|||||||
progress: 10
|
progress: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
const result = await PDFOperations.executeOperation(data.operation, data);
|
||||||
|
|
||||||
switch (data.operation) {
|
|
||||||
case 'merge':
|
|
||||||
result = await pdfMerge(data);
|
|
||||||
break;
|
|
||||||
case 'split':
|
|
||||||
result = await pdfSplit(data);
|
|
||||||
break;
|
|
||||||
case 'compress':
|
|
||||||
result = await pdfCompress(data);
|
|
||||||
break;
|
|
||||||
case 'rotate':
|
|
||||||
result = await pdfRotate(data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
result = await pdfDeletePages(data);
|
|
||||||
break;
|
|
||||||
case 'reorder':
|
|
||||||
result = await pdfReorder(data);
|
|
||||||
break;
|
|
||||||
case 'watermark':
|
|
||||||
result = await pdfWatermark(data);
|
|
||||||
break;
|
|
||||||
case 'encrypt':
|
|
||||||
result = await pdfEncrypt(data);
|
|
||||||
break;
|
|
||||||
case 'decrypt':
|
|
||||||
result = await pdfDecrypt(data);
|
|
||||||
break;
|
|
||||||
case 'permissions':
|
|
||||||
result = await pdfSetPermissions(data);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
result = { success: false, error: `Unknown operation: ${data.operation}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.webContents.send('pdf-operation-complete', result);
|
mainWindow.webContents.send('pdf-operation-complete', result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mainWindow.webContents.send('pdf-operation-complete', {
|
mainWindow.webContents.send('pdf-operation-complete', {
|
||||||
@@ -4199,12 +3845,9 @@ ipcMain.on('process-pdf-operation', async (event, data) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// IPC Handler for getting PDF page count
|
|
||||||
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
ipcMain.on('get-pdf-page-count', async (event, filePath) => {
|
||||||
try {
|
try {
|
||||||
const pdfBytes = fs.readFileSync(filePath);
|
const count = await PDFOperations.getPageCount(filePath);
|
||||||
const pdf = await PDFDocument.load(pdfBytes);
|
|
||||||
const count = pdf.getPageCount();
|
|
||||||
event.reply('pdf-page-count', { count });
|
event.reply('pdf-page-count', { count });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
event.reply('pdf-page-count', { error: error.message });
|
event.reply('pdf-page-count', { error: error.message });
|
||||||
@@ -4388,6 +4031,25 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-custom-css', async (event) => {
|
||||||
|
const result = dialog.showOpenDialogSync(mainWindow, {
|
||||||
|
title: 'Select Custom Preview CSS',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [
|
||||||
|
{ name: 'CSS Stylesheets', extensions: ['css'] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result && result[0]) {
|
||||||
|
const filePath = result[0];
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { path: filePath, content };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('read-file', async (event, filePath) => {
|
ipcMain.handle('read-file', async (event, filePath) => {
|
||||||
const validation = validatePath(filePath);
|
const validation = validatePath(filePath);
|
||||||
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
if (!validation.valid || !isPathAccessible(validation.resolved)) {
|
||||||
@@ -4512,49 +4174,24 @@ ipcMain.on('open-file-path', (event, filePath) => {
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Git IPC Handlers
|
// Git IPC Handlers
|
||||||
// ============================================
|
// ============================================
|
||||||
const simpleGit = require('simple-git');
|
|
||||||
|
|
||||||
function getGitRepo() {
|
|
||||||
// Use the directory of the current file, or the app's working directory
|
|
||||||
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
|
||||||
return simpleGit(dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('git-status', async () => {
|
ipcMain.handle('git-status', async () => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.getStatus(dir);
|
||||||
return await git.status();
|
|
||||||
} catch (err) {
|
|
||||||
return { error: 'Not a git repository' };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-stage', async (event, { files }) => {
|
ipcMain.handle('git-stage', async (event, { files }) => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.stage(dir, files);
|
||||||
await git.add(files);
|
|
||||||
return await git.status();
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-commit', async (event, { message }) => {
|
ipcMain.handle('git-commit', async (event, { message }) => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.commit(dir, message);
|
||||||
return await git.commit(message);
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('git-log', async () => {
|
ipcMain.handle('git-log', async () => {
|
||||||
try {
|
const dir = currentFile ? path.dirname(currentFile) : process.cwd();
|
||||||
const git = getGitRepo();
|
return GitOperations.log(dir);
|
||||||
return await git.log({ maxCount: 20 });
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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 (err) {
|
||||||
|
return { error: 'Not a git repository' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stage(dir, files) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
await git.add(files);
|
||||||
|
return await git.status();
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(dir, message) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
return await git.commit(message);
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function log(dir, maxCount = 20) {
|
||||||
|
try {
|
||||||
|
const git = getGitInstance(dir);
|
||||||
|
return await git.log({ maxCount });
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, stage, commit, log };
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
|
||||||
|
|
||||||
|
function parsePageRanges(rangeString, totalPages) {
|
||||||
|
const pages = [];
|
||||||
|
const ranges = rangeString.split(',').map(r => r.trim());
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
for (let i = start; i <= end && i <= totalPages; i++) {
|
||||||
|
if (i > 0 && !pages.includes(i - 1)) {
|
||||||
|
pages.push(i - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page = parseInt(range);
|
||||||
|
if (page > 0 && page <= totalPages && !pages.includes(page - 1)) {
|
||||||
|
pages.push(page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? {
|
||||||
|
r: parseInt(result[1], 16) / 255,
|
||||||
|
g: parseInt(result[2], 16) / 255,
|
||||||
|
b: parseInt(result[3], 16) / 255
|
||||||
|
} : { r: 0, g: 0, b: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfMerge(data) {
|
||||||
|
try {
|
||||||
|
const mergedPdf = await PDFDocument.create();
|
||||||
|
|
||||||
|
for (const filePath of data.inputFiles) {
|
||||||
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
||||||
|
copiedPages.forEach(page => mergedPdf.addPage(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await mergedPdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, pdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully merged ${data.inputFiles.length} PDFs` };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfSplit(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const splits = [];
|
||||||
|
|
||||||
|
if (data.splitMode === 'pages') {
|
||||||
|
const ranges = data.pageRanges.split(',').map(r => r.trim());
|
||||||
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
|
const range = ranges[i];
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
for (let p = start; p <= end && p <= totalPages; p++) {
|
||||||
|
pages.push(p - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const page = parseInt(range);
|
||||||
|
if (page > 0 && page <= totalPages) {
|
||||||
|
pages.push(page - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length > 0) {
|
||||||
|
splits.push({ pages, name: `part_${i + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'interval') {
|
||||||
|
const interval = data.interval;
|
||||||
|
for (let i = 0; i < totalPages; i += interval) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + interval && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / interval) + 1}` });
|
||||||
|
}
|
||||||
|
} else if (data.splitMode === 'size') {
|
||||||
|
const chunkSize = Math.max(1, Math.floor(totalPages / 5));
|
||||||
|
for (let i = 0; i < totalPages; i += chunkSize) {
|
||||||
|
const pages = [];
|
||||||
|
for (let j = i; j < i + chunkSize && j < totalPages; j++) {
|
||||||
|
pages.push(j);
|
||||||
|
}
|
||||||
|
splits.push({ pages, name: `part_${Math.floor(i / chunkSize) + 1}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = path.basename(data.inputPath, '.pdf');
|
||||||
|
for (const split of splits) {
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, split.pages);
|
||||||
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
|
||||||
|
const newPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(outputPath, newPdfBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Successfully split PDF into ${splits.length} files` };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfCompress(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const compressedPdfBytes = await pdf.save({
|
||||||
|
useObjectStreams: true,
|
||||||
|
addDefaultPage: false,
|
||||||
|
objectsPerTick: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, compressedPdfBytes);
|
||||||
|
|
||||||
|
const originalSize = fs.statSync(data.inputPath).size;
|
||||||
|
const compressedSize = fs.statSync(data.outputPath).size;
|
||||||
|
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfRotate(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToRotate = [];
|
||||||
|
if (data.pages && data.pages.trim()) {
|
||||||
|
pagesToRotate = parsePageRanges(data.pages, totalPages);
|
||||||
|
} else {
|
||||||
|
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesToRotate.forEach(pageIndex => {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
page.setRotation(degrees(data.angle));
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotatedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, rotatedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDeletePages(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const pagesToDelete = parsePageRanges(data.pages, totalPages);
|
||||||
|
|
||||||
|
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => {
|
||||||
|
pdf.removePage(pageIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfReorder(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1);
|
||||||
|
|
||||||
|
if (newOrder.length !== totalPages) {
|
||||||
|
return { success: false, error: `New order must include all ${totalPages} pages` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(pdf, newOrder);
|
||||||
|
copiedPages.forEach(page => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const reorderedPdfBytes = await newPdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, reorderedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully reordered PDF pages' };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfWatermark(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
const totalPages = pdf.getPageCount();
|
||||||
|
|
||||||
|
let pagesToWatermark = [];
|
||||||
|
if (data.pages === 'all') {
|
||||||
|
pagesToWatermark = Array.from({ length: totalPages }, (_, i) => i);
|
||||||
|
} else if (data.pages === 'custom' && data.customPages) {
|
||||||
|
pagesToWatermark = parsePageRanges(data.customPages, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
const color = hexToRgb(data.color);
|
||||||
|
|
||||||
|
for (const pageIndex of pagesToWatermark) {
|
||||||
|
const page = pdf.getPage(pageIndex);
|
||||||
|
const { width, height } = page.getSize();
|
||||||
|
|
||||||
|
let x, y, rotation = 0;
|
||||||
|
|
||||||
|
switch (data.position) {
|
||||||
|
case 'center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
break;
|
||||||
|
case 'diagonal':
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
rotation = 45;
|
||||||
|
break;
|
||||||
|
case 'top-left':
|
||||||
|
x = 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'top-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = height - 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-left':
|
||||||
|
x = 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-center':
|
||||||
|
x = width / 2;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
x = width - 50;
|
||||||
|
y = 50;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
x = width / 2;
|
||||||
|
y = height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawText(data.text, {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size: data.fontSize,
|
||||||
|
font,
|
||||||
|
color: rgb(color.r, color.g, color.b),
|
||||||
|
opacity: data.opacity,
|
||||||
|
rotate: degrees(rotation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const watermarkedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, watermarkedPdfBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfEncrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
|
||||||
|
const encryptedPdfBytes = await pdf.save({
|
||||||
|
userPassword: data.userPassword,
|
||||||
|
ownerPassword: data.ownerPassword || data.userPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, encryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully added password protection to PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('password')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfDecrypt(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, { password: data.password });
|
||||||
|
|
||||||
|
const decryptedPdfBytes = await pdf.save();
|
||||||
|
fs.writeFileSync(data.outputPath, decryptedPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully removed password protection from PDF' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('password') || error.message.includes('encrypted')) {
|
||||||
|
return { success: false, error: 'Incorrect password or PDF is not encrypted' };
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdfSetPermissions(data) {
|
||||||
|
try {
|
||||||
|
const pdfBytes = fs.readFileSync(data.inputPath);
|
||||||
|
const loadOptions = data.currentPassword ? { password: data.currentPassword } : {};
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes, loadOptions);
|
||||||
|
|
||||||
|
const newPdfBytes = await pdf.save({
|
||||||
|
ownerPassword: data.ownerPassword,
|
||||||
|
permissions: {
|
||||||
|
printing: data.permissions.printing ? 'highResolution' : 'lowResolution',
|
||||||
|
modifying: data.permissions.modifying,
|
||||||
|
copying: data.permissions.copying,
|
||||||
|
annotating: data.permissions.annotating,
|
||||||
|
fillingForms: data.permissions.fillingForms,
|
||||||
|
contentAccessibility: data.permissions.contentAccessibility,
|
||||||
|
documentAssembly: data.permissions.documentAssembly
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync(data.outputPath, newPdfBytes);
|
||||||
|
|
||||||
|
return { success: true, message: 'Successfully updated PDF permissions' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('encrypt') || error.message.includes('permission')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeOperation(operation, data) {
|
||||||
|
switch (operation) {
|
||||||
|
case 'merge': return pdfMerge(data);
|
||||||
|
case 'split': return pdfSplit(data);
|
||||||
|
case 'compress': return pdfCompress(data);
|
||||||
|
case 'rotate': return pdfRotate(data);
|
||||||
|
case 'delete': return pdfDeletePages(data);
|
||||||
|
case 'reorder': return pdfReorder(data);
|
||||||
|
case 'watermark': return pdfWatermark(data);
|
||||||
|
case 'encrypt': return pdfEncrypt(data);
|
||||||
|
case 'decrypt': return pdfDecrypt(data);
|
||||||
|
case 'permissions': return pdfSetPermissions(data);
|
||||||
|
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageCount(filePath) {
|
||||||
|
const pdfBytes = fs.readFileSync(filePath);
|
||||||
|
const pdf = await PDFDocument.load(pdfBytes);
|
||||||
|
return pdf.getPageCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePageRanges,
|
||||||
|
hexToRgb,
|
||||||
|
pdfMerge,
|
||||||
|
pdfSplit,
|
||||||
|
pdfCompress,
|
||||||
|
pdfRotate,
|
||||||
|
pdfDeletePages,
|
||||||
|
pdfReorder,
|
||||||
|
pdfWatermark,
|
||||||
|
pdfEncrypt,
|
||||||
|
pdfDecrypt,
|
||||||
|
pdfSetPermissions,
|
||||||
|
executeOperation,
|
||||||
|
getPageCount
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const { PluginAPI } = require('../../plugin-api');
|
||||||
|
|
||||||
|
class SamplePlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
context.commands.register('hello', 'Sample: Hello World', () => {
|
||||||
|
console.log('[SamplePlugin] Hello from the plugin system!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Plugin: SamplePlugin };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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,102 @@
|
|||||||
|
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,31 @@
|
|||||||
|
{
|
||||||
|
"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,99 @@
|
|||||||
|
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,124 @@
|
|||||||
|
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,97 @@
|
|||||||
|
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 [action, 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,81 @@
|
|||||||
|
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,73 @@
|
|||||||
|
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,51 @@
|
|||||||
|
class SprintEngine {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {function} opts.onEvent - callback(event_name, data)
|
||||||
|
*/
|
||||||
|
constructor(opts = {}) {
|
||||||
|
this.onEvent = opts.onEvent || (() => {});
|
||||||
|
this._active = false;
|
||||||
|
this._startTime = null;
|
||||||
|
this._duration = 0;
|
||||||
|
this._initialWords = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(durationMinutes, currentWordCount) {
|
||||||
|
if (this._active) throw new Error('Sprint already active');
|
||||||
|
this._active = true;
|
||||||
|
this._startTime = Date.now();
|
||||||
|
this._duration = durationMinutes * 60 * 1000;
|
||||||
|
this._initialWords = currentWordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(currentWordCount) {
|
||||||
|
if (!this._active) throw new Error('No active sprint');
|
||||||
|
const elapsed = Date.now() - this._startTime;
|
||||||
|
const wordDelta = Math.max(0, currentWordCount - this._initialWords);
|
||||||
|
const elapsedMinutes = elapsed / 60000;
|
||||||
|
const wpm = elapsedMinutes > 0 ? Math.round(wordDelta / elapsedMinutes) : 0;
|
||||||
|
this._active = false;
|
||||||
|
this._startTime = null;
|
||||||
|
return { wordDelta, elapsed, wpm };
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(elapsedMs) {
|
||||||
|
if (!this._active) return;
|
||||||
|
const remaining = Math.max(0, this._duration - elapsedMs);
|
||||||
|
this.onEvent('sprint:tick', { remaining, elapsed: elapsedMs });
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this._active = false;
|
||||||
|
this.onEvent('sprint:complete', { expired: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive() { return this._active; }
|
||||||
|
|
||||||
|
getRemaining() {
|
||||||
|
if (!this._active) return 0;
|
||||||
|
return Math.max(0, this._duration - (Date.now() - this._startTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SprintEngine };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
class EventBus {
|
||||||
|
constructor() {
|
||||||
|
this.listeners = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event).push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event, handler) {
|
||||||
|
if (!handler) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (handlers) {
|
||||||
|
const idx = handlers.indexOf(handler);
|
||||||
|
if (idx !== -1) handlers.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event, payload) {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (!handlers) return;
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
handler(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[EventBus] Error in handler for "${event}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasHandler(event) {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
return handlers !== undefined && handlers !== null && handlers.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { EventBus };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class PluginAPI {
|
||||||
|
/**
|
||||||
|
* Called when the plugin is discovered and loaded.
|
||||||
|
* Receives a scoped context object with APIs.
|
||||||
|
* @param {object} context - Plugin context (sidebar, commands, settings, etc.)
|
||||||
|
*/
|
||||||
|
init(context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the plugin is activated (e.g., sidebar panel clicked). */
|
||||||
|
activate() {}
|
||||||
|
|
||||||
|
/** Called when the plugin is deactivated. */
|
||||||
|
deactivate() {}
|
||||||
|
|
||||||
|
/** Returns the parsed manifest.json for this plugin. */
|
||||||
|
getManifest() {
|
||||||
|
return this._manifest || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginAPI };
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
class PluginContext {
|
||||||
|
/**
|
||||||
|
* @param {object} deps - Injected dependencies
|
||||||
|
* @param {string} deps.pluginId - Plugin unique ID (for namespacing)
|
||||||
|
* @param {object} deps.sidebar - SidebarManager.registerPanel
|
||||||
|
* @param {object} deps.commands - CommandPalette.register
|
||||||
|
* @param {object} deps.statusBar - StatusBar.registerIndicator
|
||||||
|
* @param {object} deps.eventBus - EventBus instance
|
||||||
|
* @param {object} deps.settings - { get, set, onChanged }
|
||||||
|
* @param {object} deps.editor - { getContent, getSelection, insertAtCursor, onContentChanged }
|
||||||
|
* @param {object} deps.ipc - { invoke, on }
|
||||||
|
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps;
|
||||||
|
|
||||||
|
this.sidebar = {
|
||||||
|
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.commands = {
|
||||||
|
register: (id, label, handler, shortcut) => {
|
||||||
|
const safeHandler = (...args) => {
|
||||||
|
try {
|
||||||
|
handler(...args);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugin:${pluginId}] Command "${id}" error:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.statusBar = {
|
||||||
|
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.settings = {
|
||||||
|
get: (key) => settings.get(`plugins.${pluginId}.${key}`),
|
||||||
|
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
|
||||||
|
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.editor = {
|
||||||
|
getContent: () => editor.getContent(),
|
||||||
|
getSelection: () => editor.getSelection(),
|
||||||
|
insertAtCursor: (text) => editor.insertAtCursor(text),
|
||||||
|
onContentChanged: (cb) => editor.onContentChanged(cb)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.events = {
|
||||||
|
on: (event, handler) => eventBus.on(event, handler),
|
||||||
|
off: (event, handler) => eventBus.off(event, handler),
|
||||||
|
emit: (event, payload) => eventBus.emit(event, payload),
|
||||||
|
hasHandler: (event) => eventBus.hasHandler(event)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ipc = {
|
||||||
|
invoke: (channel, ...args) => ipc.invoke(channel, ...args),
|
||||||
|
on: (channel, handler) => ipc.on(channel, handler)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.exports = {
|
||||||
|
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); },
|
||||||
|
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginContext };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class PluginLoader {
|
||||||
|
/**
|
||||||
|
* @param {string[]} searchDirs - Directories to scan for plugin folders
|
||||||
|
*/
|
||||||
|
constructor(searchDirs = []) {
|
||||||
|
this.searchDirs = searchDirs;
|
||||||
|
this.loadedIds = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover plugins by scanning searchDirs for manifest.json files.
|
||||||
|
* Returns array of { id, name, version, description, manifest, PluginClass, dir }
|
||||||
|
*/
|
||||||
|
discoverPlugins() {
|
||||||
|
const plugins = [];
|
||||||
|
for (const dir of this.searchDirs) {
|
||||||
|
if (!fs.existsSync(dir)) continue;
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const pluginDir = path.join(dir, entry.name);
|
||||||
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||||
|
const manifest = JSON.parse(raw);
|
||||||
|
this.validateManifest(manifest);
|
||||||
|
let PluginClass = null;
|
||||||
|
const indexPath = path.join(pluginDir, 'index.js');
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
try {
|
||||||
|
const loaded = require(indexPath);
|
||||||
|
PluginClass = loaded.Plugin || loaded.default || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins.push({
|
||||||
|
id: manifest.id,
|
||||||
|
name: manifest.name,
|
||||||
|
version: manifest.version,
|
||||||
|
description: manifest.description,
|
||||||
|
manifest,
|
||||||
|
PluginClass,
|
||||||
|
dir: pluginDir
|
||||||
|
});
|
||||||
|
this.loadedIds.add(manifest.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginLoader] Skipping plugin in ${pluginDir}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a manifest object. Throws on invalid.
|
||||||
|
*/
|
||||||
|
validateManifest(manifest) {
|
||||||
|
if (!manifest.id) throw new Error('Manifest missing required field: id');
|
||||||
|
if (!manifest.name) throw new Error('Manifest missing required field: name');
|
||||||
|
if (!manifest.version) throw new Error('Manifest missing required field: version');
|
||||||
|
if (!manifest.description) throw new Error('Manifest missing required field: description');
|
||||||
|
if (this.loadedIds.has(manifest.id)) {
|
||||||
|
throw new Error(`Duplicate plugin id: "${manifest.id}"`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginLoader };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { PluginContext } = require('./plugin-context');
|
||||||
|
const { PluginAPI } = require('./plugin-api');
|
||||||
|
|
||||||
|
class PluginRegistry {
|
||||||
|
constructor(deps) {
|
||||||
|
this.deps = deps;
|
||||||
|
this.plugins = new Map();
|
||||||
|
this.exportHooks = { preHooks: [], postHooks: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a discovered plugin. Creates instance, builds context, calls init.
|
||||||
|
* If init throws, plugin is NOT registered.
|
||||||
|
*/
|
||||||
|
register(pluginInfo) {
|
||||||
|
const { id, name, version, description, manifest, PluginClass, dir } = pluginInfo;
|
||||||
|
|
||||||
|
let instance;
|
||||||
|
if (PluginClass) {
|
||||||
|
instance = new PluginClass();
|
||||||
|
} else {
|
||||||
|
instance = new PluginAPI();
|
||||||
|
}
|
||||||
|
instance._manifest = manifest;
|
||||||
|
|
||||||
|
const context = new PluginContext({
|
||||||
|
pluginId: id,
|
||||||
|
sidebar: this.deps.sidebar,
|
||||||
|
commands: this.deps.commands,
|
||||||
|
statusBar: this.deps.statusBar,
|
||||||
|
eventBus: this.deps.eventBus,
|
||||||
|
settings: this.deps.settings,
|
||||||
|
editor: this.deps.editor,
|
||||||
|
ipc: this.deps.ipc,
|
||||||
|
exportHooks: this.exportHooks
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
instance.init(context);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" init failed:`, err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plugins.set(id, { id, name, version, description, manifest, instance, dir, context });
|
||||||
|
console.log(`[PluginRegistry] Registered plugin: ${name} v${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlugin(id) {
|
||||||
|
return this.plugins.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
return Array.from(this.plugins.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(id) {
|
||||||
|
const plugin = this.plugins.get(id);
|
||||||
|
if (plugin?.instance) {
|
||||||
|
try {
|
||||||
|
plugin.instance.activate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" activate error:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(id) {
|
||||||
|
const plugin = this.plugins.get(id);
|
||||||
|
if (plugin?.instance) {
|
||||||
|
try {
|
||||||
|
plugin.instance.deactivate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[PluginRegistry] Plugin "${id}" deactivate error:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginRegistry };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
class SettingsStore {
|
||||||
|
/**
|
||||||
|
* @param {object} backend - { get(key), set(key, value) } backed by main process store
|
||||||
|
*/
|
||||||
|
constructor(backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
return this.backend.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
this.backend.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanged(key, callback) {
|
||||||
|
// Deferred: plugins read settings on init/activate for MVP.
|
||||||
|
// Full change notification requires IPC watcher in main process.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SettingsStore };
|
||||||
+12
-3
@@ -9,7 +9,7 @@
|
|||||||
* - All IPC channels are explicitly whitelisted
|
* - All IPC channels are explicitly whitelisted
|
||||||
* - Prevents XSS from escalating to full system access
|
* - Prevents XSS from escalating to full system access
|
||||||
*
|
*
|
||||||
* @version 4.1.0
|
* @version 4.4.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
@@ -23,6 +23,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'save-recent-files',
|
'save-recent-files',
|
||||||
'clear-recent-files',
|
'clear-recent-files',
|
||||||
'renderer-ready',
|
'renderer-ready',
|
||||||
|
'select-custom-css',
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
'get-theme',
|
'get-theme',
|
||||||
@@ -135,7 +136,11 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'export',
|
'export',
|
||||||
|
|
||||||
// Git diff
|
// Git diff
|
||||||
'git-diff'
|
'git-diff',
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
'plugin-settings:get',
|
||||||
|
'plugin-settings:set'
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_RECEIVE_CHANNELS = [
|
const ALLOWED_RECEIVE_CHANNELS = [
|
||||||
@@ -146,6 +151,8 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'get-content-for-save',
|
'get-content-for-save',
|
||||||
'get-content-for-spreadsheet',
|
'get-content-for-spreadsheet',
|
||||||
'recent-files-cleared',
|
'recent-files-cleared',
|
||||||
|
'load-custom-css',
|
||||||
|
'clear-custom-css',
|
||||||
|
|
||||||
// UI toggles
|
// UI toggles
|
||||||
'toggle-preview',
|
'toggle-preview',
|
||||||
@@ -429,7 +436,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
generators: {
|
generators: {
|
||||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||||
openTable: () => ipcRenderer.send('open-table-generator')
|
openTable: () => ipcRenderer.send('open-table-generator')
|
||||||
}
|
},
|
||||||
|
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log successful preload initialization
|
// Log successful preload initialization
|
||||||
|
|||||||
+583
-64
@@ -1,16 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* MarkdownConverter Renderer Process
|
* MarkdownConverter Renderer Process
|
||||||
* @version 4.1.0
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { ipcRenderer } = require('electron');
|
const { ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// Shim window.electronAPI for main window which uses nodeIntegration
|
||||||
|
// without preload script (window.electronAPI is normally set by preload.js).
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
invoke: (channel, data) => ipcRenderer.invoke(channel, data),
|
||||||
|
on: (channel, callback) => ipcRenderer.on(channel, (event, ...args) => callback(...args))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const marked = require('marked');
|
const marked = require('marked');
|
||||||
const { markedHighlight } = require('marked-highlight');
|
const { markedHighlight } = require('marked-highlight');
|
||||||
const DOMPurify = require('dompurify');
|
const createDOMPurify = require('dompurify');
|
||||||
|
const DOMPurify = createDOMPurify(window);
|
||||||
const hljs = require('highlight.js');
|
const hljs = require('highlight.js');
|
||||||
const { createEditor } = require('./editor/codemirror-setup');
|
const { createEditor } = require('./editor/codemirror-setup');
|
||||||
const { undo, redo } = require('@codemirror/commands');
|
const { undo, redo } = require('@codemirror/commands');
|
||||||
const { ModalManager } = require('./utils/ModalManager');
|
// ModalManager is loaded via <script src="utils/ModalManager.js"> in index.html.
|
||||||
|
// It already exists in the global scope — re-declaring with let/const causes
|
||||||
|
// SyntaxError: "Identifier 'ModalManager' has already been declared".
|
||||||
|
if (typeof ModalManager === 'undefined') {
|
||||||
|
const result = require('./utils/ModalManager');
|
||||||
|
ModalManager = result.ModalManager || result;
|
||||||
|
}
|
||||||
// Lazy-loaded modules — defer heavy imports until first use
|
// Lazy-loaded modules — defer heavy imports until first use
|
||||||
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
||||||
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
|
let _ReplPanel, _CommandPalette, _PrintPreview, _createWelcomeContent;
|
||||||
@@ -101,19 +119,24 @@ marked.use({
|
|||||||
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||||
tokenizer(src) {
|
tokenizer(src) {
|
||||||
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
if (match) {
|
if (match && match.index === 0) {
|
||||||
|
const admonitionType = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
const tokens = [];
|
||||||
|
this.lexer.blockTokens(text, tokens);
|
||||||
return {
|
return {
|
||||||
type: 'admonition',
|
type: 'admonition',
|
||||||
raw: match[0],
|
raw: match[0],
|
||||||
admonitionType: match[1],
|
admonitionType,
|
||||||
text: match[2].trim()
|
text,
|
||||||
|
tokens
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer(token) {
|
renderer(token) {
|
||||||
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
|
const icons = { note: '\u2139', warning: '\u26A0', tip: '\uD83D\uDCA1', danger: '\uD83D\uDD34', info: '\u2139' };
|
||||||
const icon = icons[token.admonitionType] || '\u2139';
|
const icon = icons[token.admonitionType] || '\u2139';
|
||||||
const inner = this.parser.parse(token.text);
|
const inner = this.parser.parse(token.tokens || []);
|
||||||
return `<div class="admonition admonition-${token.admonitionType}">
|
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-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||||
<div class="admonition-content">${inner}</div>
|
<div class="admonition-content">${inner}</div>
|
||||||
@@ -130,6 +153,21 @@ function plantumlEncode(text) {
|
|||||||
return '~h' + hex;
|
return '~h' + hex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scopes user custom CSS by prefixing all selectors to protect application UI
|
||||||
|
function 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 || '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level reference set by DOMContentLoaded after sidebar registration
|
||||||
|
const outlinePanelContainer = null;
|
||||||
|
|
||||||
// Tab Management
|
// Tab Management
|
||||||
class TabManager {
|
class TabManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -143,6 +181,7 @@ class TabManager {
|
|||||||
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
this.recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
||||||
this.previewDebounceTimers = new Map(); // Debounce timers per tab
|
this.previewDebounceTimers = new Map(); // Debounce timers per tab
|
||||||
this.previewDebounceDelay = 300; // 300ms debounce
|
this.previewDebounceDelay = 300; // 300ms debounce
|
||||||
|
this.previewCache = new Map(); // Performance optimization render cache
|
||||||
|
|
||||||
// Initialize first tab
|
// Initialize first tab
|
||||||
this.tabs.set(1, {
|
this.tabs.set(1, {
|
||||||
@@ -226,7 +265,7 @@ class TabManager {
|
|||||||
editorView: null,
|
editorView: null,
|
||||||
findMatches: [],
|
findMatches: [],
|
||||||
currentMatchIndex: -1,
|
currentMatchIndex: -1,
|
||||||
type: type,
|
type: type, // 'markdown' or 'pdf'
|
||||||
// PDF-specific state
|
// PDF-specific state
|
||||||
pdfDoc: null,
|
pdfDoc: null,
|
||||||
pdfCurrentPage: 1,
|
pdfCurrentPage: 1,
|
||||||
@@ -239,6 +278,7 @@ class TabManager {
|
|||||||
this.switchToTab(newTabId);
|
this.switchToTab(newTabId);
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
|
console.log('createNewTab: tab created with type:', type);
|
||||||
}
|
}
|
||||||
|
|
||||||
createPdfTab(filePath) {
|
createPdfTab(filePath) {
|
||||||
@@ -450,27 +490,7 @@ class TabManager {
|
|||||||
document.querySelector('.editor-container').appendChild(tabContent);
|
document.querySelector('.editor-container').appendChild(tabContent);
|
||||||
|
|
||||||
// Initialize CodeMirror editor
|
// Initialize CodeMirror editor
|
||||||
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
this.ensureEditor(tab);
|
||||||
if (editorContainer) {
|
|
||||||
const isDark = document.body.className.includes('dark');
|
|
||||||
tab.editorView = createEditor(editorContainer, {
|
|
||||||
content: tab.content,
|
|
||||||
onChange: (newContent) => {
|
|
||||||
tab.content = newContent;
|
|
||||||
tab.isDirty = true;
|
|
||||||
this.updatePreview(tab.id);
|
|
||||||
this.updateWordCount();
|
|
||||||
this.updateTabBar();
|
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
|
||||||
},
|
|
||||||
onUpdate: (view) => {
|
|
||||||
this.updateCursorPosition(view);
|
|
||||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
|
||||||
},
|
|
||||||
isDark,
|
|
||||||
showLineNumbers: this.showLineNumbers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToTab(tabId) {
|
switchToTab(tabId) {
|
||||||
@@ -656,6 +676,11 @@ class TabManager {
|
|||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
if (!tab || tab.type === 'pdf') return;
|
if (!tab || tab.type === 'pdf') return;
|
||||||
|
|
||||||
|
// Guard against auto-renders in large file mode
|
||||||
|
if (tab.largeFileMode && !immediate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing debounce timer for this tab
|
// Clear existing debounce timer for this tab
|
||||||
if (this.previewDebounceTimers.has(tabId)) {
|
if (this.previewDebounceTimers.has(tabId)) {
|
||||||
clearTimeout(this.previewDebounceTimers.get(tabId));
|
clearTimeout(this.previewDebounceTimers.get(tabId));
|
||||||
@@ -674,20 +699,55 @@ class TabManager {
|
|||||||
}, this.previewDebounceDelay));
|
}, this.previewDebounceDelay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_hash(str) {
|
||||||
|
if (!str) return 0;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
_renderPreview(tabId) {
|
_renderPreview(tabId) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
const preview = document.getElementById(`preview-${tabId}`);
|
const preview = document.getElementById(`preview-${tabId}`);
|
||||||
|
console.log('[_renderPreview] tabId:', tabId, 'tab exists:', !!tab, 'preview exists:', !!preview, 'content length:', tab?.content?.length);
|
||||||
|
|
||||||
if (!tab || !preview) return;
|
if (!tab || !preview) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if libraries are available
|
// Check if libraries are available
|
||||||
if (!marked || !DOMPurify) {
|
if (!marked || !DOMPurify) {
|
||||||
|
console.error('[_renderPreview] Libraries not available:', { marked: !!marked, DOMPurify: !!DOMPurify });
|
||||||
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
|
preview.innerHTML = '<div class="preview-error"><div class="preview-error-icon">⚠️</div><div class="preview-error-title">Libraries Not Loaded</div><div class="preview-error-message">Required libraries (marked/DOMPurify) could not be loaded. Please check your installation.</div></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache lookup
|
||||||
|
const contentHash = this._hash(tab.content);
|
||||||
|
let sanitizedHtml;
|
||||||
|
if (this.previewCache.has(contentHash)) {
|
||||||
|
console.log('[_renderPreview] Cache hit for hash:', contentHash);
|
||||||
|
sanitizedHtml = this.previewCache.get(contentHash);
|
||||||
|
} else {
|
||||||
|
console.log('[_renderPreview] Cache miss. About to call marked.parse, content length:', tab.content.length);
|
||||||
const html = marked.parse(tab.content);
|
const html = marked.parse(tab.content);
|
||||||
let sanitizedHtml = DOMPurify.sanitize(html);
|
console.log('[_renderPreview] marked.parse returned type:', typeof html, 'isPromise:', html instanceof Promise);
|
||||||
|
if (html instanceof Promise) {
|
||||||
|
console.error('[_renderPreview] marked.parse returned a Promise! Need to await it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sanitizedHtml = DOMPurify.sanitize(html);
|
||||||
|
console.log('[_renderPreview] DOMPurify.sanitize returned, length:', sanitizedHtml.length);
|
||||||
|
|
||||||
|
// Add to cache and maintain cache size limit (100 entries)
|
||||||
|
this.previewCache.set(contentHash, sanitizedHtml);
|
||||||
|
if (this.previewCache.size > 100) {
|
||||||
|
const oldestKey = this.previewCache.keys().next().value;
|
||||||
|
this.previewCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TOC generation
|
// TOC generation
|
||||||
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
|
if (sanitizedHtml.includes('[[toc]]') || sanitizedHtml.includes('[TOC]')) {
|
||||||
@@ -1323,10 +1383,22 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkForLargeFile(tab, content) {
|
||||||
|
if (content && content.length > 1024 * 1024) { // >1MB
|
||||||
|
tab.largeFileMode = true;
|
||||||
|
this.isPreviewVisible = false;
|
||||||
|
this.updatePreviewVisibility();
|
||||||
|
notifyUser('Large file detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
|
||||||
|
} else {
|
||||||
|
tab.largeFileMode = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// File operations
|
// File operations
|
||||||
openFile(filePath, content) {
|
openFile(filePath, content) {
|
||||||
console.log('openFile called with:', filePath, 'content length:', content.length);
|
console.log('openFile called with:', filePath, 'content length:', content.length);
|
||||||
let tab = this.tabs.get(this.activeTabId);
|
let tab = this.tabs.get(this.activeTabId);
|
||||||
|
console.log('openFile tab state before:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length, filePath: tab?.filePath, isDirty: tab?.isDirty });
|
||||||
|
|
||||||
// Handle both forward and back slashes for cross-platform compatibility
|
// Handle both forward and back slashes for cross-platform compatibility
|
||||||
const fileName = filePath.split(/[\\/]/).pop();
|
const fileName = filePath.split(/[\\/]/).pop();
|
||||||
@@ -1340,24 +1412,31 @@ class TabManager {
|
|||||||
tab.originalContent = content;
|
tab.originalContent = content;
|
||||||
tab.isDirty = false;
|
tab.isDirty = false;
|
||||||
|
|
||||||
|
this.checkForLargeFile(tab, content);
|
||||||
|
|
||||||
// Update the editor and preview
|
// Update the editor and preview
|
||||||
this.setEditorContent(this.activeTabId, content);
|
this.setEditorContent(this.activeTabId, content);
|
||||||
this.updatePreview(this.activeTabId);
|
console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
|
||||||
|
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
|
||||||
this.updateWordCount();
|
this.updateWordCount();
|
||||||
} else {
|
} else {
|
||||||
// Create new tab for the file
|
// Create new tab for the file
|
||||||
console.log('Creating new tab for file');
|
console.log('Creating new tab for file');
|
||||||
this.createNewTab();
|
this.createNewTab();
|
||||||
tab = this.tabs.get(this.activeTabId);
|
tab = this.tabs.get(this.activeTabId);
|
||||||
|
console.log('openFile after createNewTab, tab:', { id: tab.id, hasEditorView: !!tab.editorView });
|
||||||
tab.filePath = filePath;
|
tab.filePath = filePath;
|
||||||
tab.title = fileName;
|
tab.title = fileName;
|
||||||
tab.content = content;
|
tab.content = content;
|
||||||
tab.originalContent = content;
|
tab.originalContent = content;
|
||||||
tab.isDirty = false;
|
tab.isDirty = false;
|
||||||
|
|
||||||
|
this.checkForLargeFile(tab, content);
|
||||||
|
|
||||||
// Set content in the CodeMirror editor
|
// Set content in the CodeMirror editor
|
||||||
this.setEditorContent(this.activeTabId, content);
|
this.setEditorContent(this.activeTabId, content);
|
||||||
this.updatePreview(this.activeTabId);
|
console.log('openFile after setEditorContent, tab state:', { id: tab.id, hasEditorView: !!tab.editorView, contentLength: tab.content.length });
|
||||||
|
this.updatePreview(this.activeTabId, true); // immediate=true for initial load
|
||||||
this.updateWordCount();
|
this.updateWordCount();
|
||||||
}
|
}
|
||||||
this.startAutoSave();
|
this.startAutoSave();
|
||||||
@@ -1365,6 +1444,7 @@ class TabManager {
|
|||||||
this.updateTabBar();
|
this.updateTabBar();
|
||||||
this.updateFilePath();
|
this.updateFilePath();
|
||||||
this.updateBreadcrumb();
|
this.updateBreadcrumb();
|
||||||
|
this.updateUI(); // Ensure tab content is visible
|
||||||
|
|
||||||
// Notify main process about current file for exports
|
// Notify main process about current file for exports
|
||||||
ipcRenderer.send('set-current-file', filePath);
|
ipcRenderer.send('set-current-file', filePath);
|
||||||
@@ -1381,9 +1461,47 @@ class TabManager {
|
|||||||
return tab?.content || '';
|
return tab?.content || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure a tab has a CodeMirror editor (lazy creation fallback)
|
||||||
|
ensureEditor(tab) {
|
||||||
|
if (tab.editorView) return true;
|
||||||
|
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
||||||
|
if (!editorContainer) {
|
||||||
|
console.error(`[ensureEditor] editor-cm-${tab.id} not found in DOM`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isDark = document.body.className.includes('dark');
|
||||||
|
tab.editorView = createEditor(editorContainer, {
|
||||||
|
content: tab.content,
|
||||||
|
onChange: (newContent) => {
|
||||||
|
tab.content = newContent;
|
||||||
|
tab.isDirty = true;
|
||||||
|
this.updatePreview(tab.id);
|
||||||
|
this.updateWordCount();
|
||||||
|
this.updateTabBar();
|
||||||
|
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||||
|
},
|
||||||
|
onUpdate: (view) => {
|
||||||
|
this.updateCursorPosition(view);
|
||||||
|
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||||
|
},
|
||||||
|
isDark,
|
||||||
|
showLineNumbers: this.showLineNumbers,
|
||||||
|
});
|
||||||
|
console.log(`[ensureEditor] Created editor for tab ${tab.id}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ensureEditor] Failed to create editor for tab ${tab.id}:`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set content in editor
|
// Set content in editor
|
||||||
setEditorContent(tabId, content) {
|
setEditorContent(tabId, content) {
|
||||||
const tab = this.tabs.get(tabId);
|
const tab = this.tabs.get(tabId);
|
||||||
|
if (!tab?.editorView) {
|
||||||
|
this.ensureEditor(tab);
|
||||||
|
}
|
||||||
if (tab?.editorView) {
|
if (tab?.editorView) {
|
||||||
tab.editorView.dispatch({
|
tab.editorView.dispatch({
|
||||||
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
||||||
@@ -1439,11 +1557,24 @@ class TabManager {
|
|||||||
let tabManager;
|
let tabManager;
|
||||||
let replPanel;
|
let replPanel;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
tabManager = new TabManager();
|
tabManager = new TabManager();
|
||||||
|
|
||||||
|
// Load saved Custom Preview CSS if present
|
||||||
|
const savedCSSContent = localStorage.getItem('customPreviewCSSContent');
|
||||||
|
if (savedCSSContent) {
|
||||||
|
applyCustomPreviewCSS(savedCSSContent);
|
||||||
|
}
|
||||||
|
|
||||||
const ReplPanel = getReplPanel();
|
const ReplPanel = getReplPanel();
|
||||||
replPanel = new ReplPanel();
|
replPanel = new ReplPanel();
|
||||||
|
|
||||||
|
// Populate app version from main process
|
||||||
|
window.electronAPI.getAppVersion().then((version) => {
|
||||||
|
const el = document.getElementById('app-version-display');
|
||||||
|
if (el) el.textContent = `v${version}`;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize ModalManager for all dialogs
|
// Initialize ModalManager for all dialogs
|
||||||
const findModal = new ModalManager('#find-dialog');
|
const findModal = new ModalManager('#find-dialog');
|
||||||
const exportModal = new ModalManager('#export-dialog');
|
const exportModal = new ModalManager('#export-dialog');
|
||||||
@@ -1473,7 +1604,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Initialize sidebar
|
// Initialize sidebar
|
||||||
const SidebarManager = getSidebarManager();
|
const SidebarManager = getSidebarManager();
|
||||||
const sidebarManager = new SidebarManager();
|
const sidebarManager = new SidebarManager();
|
||||||
let explorerCurrentDir = null;
|
const explorerCurrentDir = null;
|
||||||
|
|
||||||
sidebarManager.registerPanel('explorer', {
|
sidebarManager.registerPanel('explorer', {
|
||||||
title: 'Explorer',
|
title: 'Explorer',
|
||||||
@@ -1545,6 +1676,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Plugin System ---
|
||||||
|
const { PluginLoader } = require('./plugins/plugin-loader');
|
||||||
|
const { PluginRegistry } = require('./plugins/plugin-registry');
|
||||||
|
const { EventBus } = require('./plugins/event-bus');
|
||||||
|
const { SettingsStore } = require('./plugins/settings-store');
|
||||||
|
const pluginPath = require('path');
|
||||||
|
|
||||||
|
const pluginEventBus = new EventBus();
|
||||||
|
const pluginSettings = new SettingsStore({
|
||||||
|
get: (key) => window.electronAPI.invoke('plugin-settings:get', key),
|
||||||
|
set: (key, value) => window.electronAPI.invoke('plugin-settings:set', { key, value })
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusBarRight = document.querySelector('.status-bar-right');
|
||||||
|
const pluginRegistry = new PluginRegistry({
|
||||||
|
sidebar: sidebarManager,
|
||||||
|
commands: commandPalette,
|
||||||
|
statusBar: {
|
||||||
|
registerIndicator: (id, opts) => {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'status-item';
|
||||||
|
el.id = `plugin-status-${id}`;
|
||||||
|
if (opts.tooltip) el.title = opts.tooltip;
|
||||||
|
el.textContent = opts.text || '';
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.className = 'status-separator';
|
||||||
|
sep.textContent = '|';
|
||||||
|
if (statusBarRight) {
|
||||||
|
statusBarRight.appendChild(sep);
|
||||||
|
statusBarRight.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventBus: pluginEventBus,
|
||||||
|
settings: pluginSettings,
|
||||||
|
editor: {
|
||||||
|
getContent: () => tabManager.getCurrentContent(),
|
||||||
|
getSelection: () => tabManager.getSelection(),
|
||||||
|
insertAtCursor: (text) => tabManager.insertAtCursor(text),
|
||||||
|
onContentChanged: (cb) => pluginEventBus.on('document:changed', cb)
|
||||||
|
},
|
||||||
|
ipc: {
|
||||||
|
invoke: (ch, data) => window.electronAPI.invoke(ch, data),
|
||||||
|
on: (ch, cb) => window.electronAPI.on(ch, cb)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtInDir = pluginPath.join(__dirname, 'plugins', 'built-in');
|
||||||
|
const loader = new PluginLoader([builtInDir]);
|
||||||
|
const discovered = loader.discoverPlugins();
|
||||||
|
discovered.forEach(p => pluginRegistry.register(p));
|
||||||
|
|
||||||
// Initialize Zen Mode
|
// Initialize Zen Mode
|
||||||
const ZenModeClass = getZenMode();
|
const ZenModeClass = getZenMode();
|
||||||
const zenMode = new ZenModeClass(tabManager);
|
const zenMode = new ZenModeClass(tabManager);
|
||||||
@@ -1558,10 +1741,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Set welcome content in the first tab's preview
|
// Set welcome content in the first tab's preview
|
||||||
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
const recentFiles = JSON.parse(localStorage.getItem('recentFiles') || '[]');
|
||||||
const welcomeHtml = getCreateWelcomeContent()(recentFiles);
|
const appVersion = await window.electronAPI.getAppVersion().catch(() => '');
|
||||||
|
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
|
||||||
|
|
||||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
if (tab) {
|
// Only show welcome if no file was opened while we were awaiting
|
||||||
|
if (tab && !tab.filePath && tab.content === '') {
|
||||||
tab.title = 'Welcome';
|
tab.title = 'Welcome';
|
||||||
tab.content = '';
|
tab.content = '';
|
||||||
const preview = document.getElementById(`preview-${tab.id}`);
|
const preview = document.getElementById(`preview-${tab.id}`);
|
||||||
@@ -1703,6 +1888,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
||||||
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
|
||||||
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
|
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
|
||||||
|
commandPalette.register('Load Custom Preview CSS', '', triggerLoadCustomCSS);
|
||||||
|
commandPalette.register('Clear Custom Preview CSS', '', triggerClearCustomCSS);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
@@ -1728,27 +1915,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize CodeMirror for the initial tab (tab 1)
|
// Initialize CodeMirror for the initial tab (tab 1)
|
||||||
const initialEditorContainer = document.getElementById('editor-cm-1');
|
const initialTab = tabManager.tabs.get(1);
|
||||||
if (initialEditorContainer) {
|
if (initialTab) {
|
||||||
const tab = tabManager.tabs.get(1);
|
tabManager.ensureEditor(initialTab);
|
||||||
const isDark = document.body.className.includes('dark');
|
|
||||||
tab.editorView = createEditor(initialEditorContainer, {
|
|
||||||
content: tab.content,
|
|
||||||
onChange: (newContent) => {
|
|
||||||
tab.content = newContent;
|
|
||||||
tab.isDirty = true;
|
|
||||||
tabManager.updatePreview(tab.id);
|
|
||||||
tabManager.updateWordCount();
|
|
||||||
tabManager.updateTabBar();
|
|
||||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
|
||||||
},
|
|
||||||
onUpdate: (view) => {
|
|
||||||
tabManager.updateCursorPosition(view);
|
|
||||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
|
||||||
},
|
|
||||||
isDark,
|
|
||||||
showLineNumbers: tabManager.showLineNumbers,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request current theme
|
// Request current theme
|
||||||
@@ -1772,6 +1941,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPC event listeners
|
||||||
// IPC event listeners
|
// IPC event listeners
|
||||||
ipcRenderer.on('file-new', () => {
|
ipcRenderer.on('file-new', () => {
|
||||||
tabManager.createNewTab();
|
tabManager.createNewTab();
|
||||||
@@ -1779,8 +1949,20 @@ ipcRenderer.on('file-new', () => {
|
|||||||
|
|
||||||
ipcRenderer.on('file-opened', (event, data) => {
|
ipcRenderer.on('file-opened', (event, data) => {
|
||||||
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
|
console.log('[RENDERER] file-opened received:', data.path, 'content length:', data.content.length);
|
||||||
|
console.log('[RENDERER] activeTabId before openFile:', tabManager.activeTabId);
|
||||||
|
// Diagnostic: check library state
|
||||||
|
console.log('[RENDERER] Library state:', {
|
||||||
|
marked: typeof marked,
|
||||||
|
DOMPurify: typeof DOMPurify,
|
||||||
|
markedParse: typeof marked?.parse,
|
||||||
|
dompurifySanitize: typeof DOMPurify?.sanitize,
|
||||||
|
tabManagerExists: !!tabManager
|
||||||
|
});
|
||||||
if (tabManager) {
|
if (tabManager) {
|
||||||
tabManager.openFile(data.path, data.content);
|
tabManager.openFile(data.path, data.content);
|
||||||
|
console.log('[RENDERER] activeTabId after openFile:', tabManager.activeTabId);
|
||||||
|
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||||
|
console.log('[RENDERER] tab state:', { id: tab?.id, hasEditorView: !!tab?.editorView, contentLength: tab?.content?.length });
|
||||||
} else {
|
} else {
|
||||||
console.error('[RENDERER] tabManager not initialized!');
|
console.error('[RENDERER] tabManager not initialized!');
|
||||||
}
|
}
|
||||||
@@ -1859,6 +2041,49 @@ ipcRenderer.on('redo', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom Preview CSS event handlers and trigger helpers
|
||||||
|
function applyCustomPreviewCSS(cssContent) {
|
||||||
|
let styleTag = document.getElementById('custom-preview-style');
|
||||||
|
if (!styleTag) {
|
||||||
|
styleTag = document.createElement('style');
|
||||||
|
styleTag.id = 'custom-preview-style';
|
||||||
|
document.head.appendChild(styleTag);
|
||||||
|
}
|
||||||
|
const scopedCSS = scopeCSS(cssContent, '.preview-content');
|
||||||
|
styleTag.textContent = scopedCSS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerLoadCustomCSS() {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.invoke('select-custom-css');
|
||||||
|
if (result) {
|
||||||
|
console.log('[RENDERER] Custom CSS loaded from:', result.path);
|
||||||
|
localStorage.setItem('customPreviewCSSPath', result.path);
|
||||||
|
localStorage.setItem('customPreviewCSSContent', result.content);
|
||||||
|
applyCustomPreviewCSS(result.content);
|
||||||
|
notifyUser('Custom preview stylesheet applied successfully.', 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RENDERER] Error loading custom CSS:', err);
|
||||||
|
notifyUser('Failed to load custom CSS stylesheet.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerClearCustomCSS() {
|
||||||
|
localStorage.removeItem('customPreviewCSSPath');
|
||||||
|
localStorage.removeItem('customPreviewCSSContent');
|
||||||
|
const styleTag = document.getElementById('custom-preview-style');
|
||||||
|
if (styleTag) {
|
||||||
|
styleTag.remove();
|
||||||
|
}
|
||||||
|
notifyUser('Custom preview stylesheet cleared.', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on('load-custom-css', triggerLoadCustomCSS);
|
||||||
|
ipcRenderer.on('clear-custom-css', triggerClearCustomCSS);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Font size adjustment
|
// Font size adjustment
|
||||||
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
|
let currentFontSize = parseInt(localStorage.getItem('fontSize')) || 15;
|
||||||
|
|
||||||
@@ -1978,13 +2203,39 @@ function initializeExportForm(format) {
|
|||||||
document.getElementById('export-citeproc').checked = false;
|
document.getElementById('export-citeproc').checked = false;
|
||||||
document.getElementById('export-toc-depth').value = 3;
|
document.getElementById('export-toc-depth').value = 3;
|
||||||
|
|
||||||
// PDF-specific fields
|
// Toggle and reset PDF-specific fields
|
||||||
|
const pdfOnly = document.querySelector('.pdf-only');
|
||||||
|
if (pdfOnly) {
|
||||||
|
pdfOnly.style.display = (format === 'pdf') ? 'block' : 'none';
|
||||||
|
}
|
||||||
if (format === 'pdf') {
|
if (format === 'pdf') {
|
||||||
document.getElementById('pdf-engine').value = 'xelatex';
|
document.getElementById('pdf-engine').value = 'xelatex';
|
||||||
document.getElementById('pdf-geometry').value = 'margin=1in';
|
document.getElementById('pdf-geometry').value = 'margin=1in';
|
||||||
document.getElementById('custom-geometry').style.display = 'none';
|
document.getElementById('custom-geometry').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle and reset Reveal.js-specific fields
|
||||||
|
const revealjsOnly = document.querySelector('.revealjs-only');
|
||||||
|
if (revealjsOnly) {
|
||||||
|
revealjsOnly.style.display = (format === 'revealjs') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
const revealTheme = document.getElementById('reveal-theme');
|
||||||
|
if (revealTheme) revealTheme.value = 'black';
|
||||||
|
const revealTransition = document.getElementById('reveal-transition');
|
||||||
|
if (revealTransition) revealTransition.value = 'slide';
|
||||||
|
const revealSpeed = document.getElementById('reveal-speed');
|
||||||
|
if (revealSpeed) revealSpeed.value = 'default';
|
||||||
|
const revealSlideNumber = document.getElementById('reveal-slide-number');
|
||||||
|
if (revealSlideNumber) revealSlideNumber.checked = false;
|
||||||
|
const revealControls = document.getElementById('reveal-controls');
|
||||||
|
if (revealControls) revealControls.checked = true;
|
||||||
|
const revealProgress = document.getElementById('reveal-progress');
|
||||||
|
if (revealProgress) revealProgress.checked = true;
|
||||||
|
const revealHistory = document.getElementById('reveal-history');
|
||||||
|
if (revealHistory) revealHistory.checked = true;
|
||||||
|
const revealCenter = document.getElementById('reveal-center');
|
||||||
|
if (revealCenter) revealCenter.checked = true;
|
||||||
|
|
||||||
// Clear bibliography fields
|
// Clear bibliography fields
|
||||||
document.getElementById('bibliography-file').value = '';
|
document.getElementById('bibliography-file').value = '';
|
||||||
document.getElementById('csl-file').value = '';
|
document.getElementById('csl-file').value = '';
|
||||||
@@ -2044,6 +2295,18 @@ function collectExportOptions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reveal.js-specific options
|
||||||
|
if (currentExportFormat === 'revealjs') {
|
||||||
|
options.revealTheme = document.getElementById('reveal-theme').value;
|
||||||
|
options.revealTransition = document.getElementById('reveal-transition').value;
|
||||||
|
options.revealTransitionSpeed = document.getElementById('reveal-speed').value;
|
||||||
|
options.revealSlideNumber = document.getElementById('reveal-slide-number').checked;
|
||||||
|
options.revealControls = document.getElementById('reveal-controls').checked;
|
||||||
|
options.revealProgress = document.getElementById('reveal-progress').checked;
|
||||||
|
options.revealHistory = document.getElementById('reveal-history').checked;
|
||||||
|
options.revealCenter = document.getElementById('reveal-center').checked;
|
||||||
|
}
|
||||||
|
|
||||||
// Bibliography
|
// Bibliography
|
||||||
const bibFile = document.getElementById('bibliography-file').value.trim();
|
const bibFile = document.getElementById('bibliography-file').value.trim();
|
||||||
const cslFile = document.getElementById('csl-file').value.trim();
|
const cslFile = document.getElementById('csl-file').value.trim();
|
||||||
@@ -2054,6 +2317,15 @@ function collectExportOptions() {
|
|||||||
if (currentExportFormat === 'pdf') {
|
if (currentExportFormat === 'pdf') {
|
||||||
options.pdfEngine = 'xelatex';
|
options.pdfEngine = 'xelatex';
|
||||||
options.geometry = 'margin=1in';
|
options.geometry = 'margin=1in';
|
||||||
|
} else if (currentExportFormat === 'revealjs') {
|
||||||
|
options.revealTheme = 'black';
|
||||||
|
options.revealTransition = 'slide';
|
||||||
|
options.revealTransitionSpeed = 'default';
|
||||||
|
options.revealSlideNumber = false;
|
||||||
|
options.revealControls = true;
|
||||||
|
options.revealProgress = true;
|
||||||
|
options.revealHistory = true;
|
||||||
|
options.revealCenter = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2971,6 +3243,18 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
section.classList.add('hidden');
|
section.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide visual thumbnail sidebar by default
|
||||||
|
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.classList.add('hidden');
|
||||||
|
}
|
||||||
|
const body = document.querySelector('.pdf-editor-body');
|
||||||
|
if (body) {
|
||||||
|
body.classList.remove('side-by-side');
|
||||||
|
}
|
||||||
|
pdfDocInstance = null;
|
||||||
|
pdfPreviewState = { pages: [], filePath: '' };
|
||||||
|
|
||||||
// Show the appropriate section and set title
|
// Show the appropriate section and set title
|
||||||
let sectionId, titleText;
|
let sectionId, titleText;
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -3004,7 +3288,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Rotate Pages';
|
titleText = 'Rotate Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const rotateInput = document.getElementById('rotate-input-path');
|
const rotateInput = document.getElementById('rotate-input-path');
|
||||||
if (rotateInput) rotateInput.value = openedFilePath;
|
if (rotateInput) {
|
||||||
|
rotateInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('rotate-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
@@ -3012,7 +3300,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Delete Pages';
|
titleText = 'Delete Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const deleteInput = document.getElementById('delete-input-path');
|
const deleteInput = document.getElementById('delete-input-path');
|
||||||
if (deleteInput) deleteInput.value = openedFilePath;
|
if (deleteInput) {
|
||||||
|
deleteInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('delete-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'reorder':
|
case 'reorder':
|
||||||
@@ -3020,7 +3312,11 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
|
|||||||
titleText = 'Reorder Pages';
|
titleText = 'Reorder Pages';
|
||||||
if (openedFilePath) {
|
if (openedFilePath) {
|
||||||
const reorderInput = document.getElementById('reorder-input-path');
|
const reorderInput = document.getElementById('reorder-input-path');
|
||||||
if (reorderInput) reorderInput.value = openedFilePath;
|
if (reorderInput) {
|
||||||
|
reorderInput.value = openedFilePath;
|
||||||
|
// Trigger thumbnail generation
|
||||||
|
setTimeout(() => onPDFFileSelected('reorder-input-path', openedFilePath), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'watermark':
|
case 'watermark':
|
||||||
@@ -3181,6 +3477,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
document.getElementById(button.inputId).value = file.path;
|
document.getElementById(button.inputId).value = file.path;
|
||||||
|
onPDFFileSelected(button.inputId, file.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -3190,6 +3487,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
document.getElementById(button.inputId).value = file.path;
|
document.getElementById(button.inputId).value = file.path;
|
||||||
|
onPDFFileSelected(button.inputId, file.path);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
@@ -3334,7 +3632,7 @@ function showPDFValidationMessage(message, focusSelector = null) {
|
|||||||
// Process PDF Operation
|
// Process PDF Operation
|
||||||
function processPDFOperation() {
|
function processPDFOperation() {
|
||||||
const operation = currentPDFOperation;
|
const operation = currentPDFOperation;
|
||||||
let operationData = { operation };
|
const operationData = { operation };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (operation) {
|
switch (operation) {
|
||||||
@@ -4124,7 +4422,7 @@ function textToASCII(text, style) {
|
|||||||
lines[i] = '';
|
lines[i] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let char of upperText) {
|
for (const char of upperText) {
|
||||||
const charLines = font.chars[char] || font.chars[' '];
|
const charLines = font.chars[char] || font.chars[' '];
|
||||||
if (charLines) {
|
if (charLines) {
|
||||||
for (let i = 0; i < font.height; i++) {
|
for (let i = 0; i < font.height; i++) {
|
||||||
@@ -4162,7 +4460,7 @@ function createASCIIBox(text, style, padding) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Content lines
|
// Content lines
|
||||||
for (let line of lines) {
|
for (const line of lines) {
|
||||||
const paddedLine = line.padEnd(maxLength, ' ');
|
const paddedLine = line.padEnd(maxLength, ' ');
|
||||||
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
|
result += chars.v + ' '.repeat(padding) + paddedLine + ' '.repeat(padding) + chars.v + '\n';
|
||||||
}
|
}
|
||||||
@@ -4798,3 +5096,224 @@ ipcRenderer.on('show-video-tool', (event, tool) => {
|
|||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PDF EDITOR VISUAL PAGE SIDEBAR HANDLERS
|
||||||
|
// ============================================
|
||||||
|
let pdfDocInstance = null;
|
||||||
|
let pdfPreviewState = { pages: [], filePath: '' };
|
||||||
|
|
||||||
|
function onPDFFileSelected(inputId, filePath) {
|
||||||
|
const eligibleInputs = ['rotate-input-path', 'delete-input-path', 'reorder-input-path'];
|
||||||
|
if (!eligibleInputs.includes(inputId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebar = document.getElementById('pdf-thumbnail-sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
const body = document.querySelector('.pdf-editor-body');
|
||||||
|
if (body) {
|
||||||
|
body.classList.add('side-by-side');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPDFThumbnails(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPDFThumbnails(filePath) {
|
||||||
|
const grid = document.getElementById('pdf-thumbnail-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
// Reset previous inputs
|
||||||
|
const rotateInput = document.getElementById('rotate-pages');
|
||||||
|
if (rotateInput) rotateInput.value = '';
|
||||||
|
const deleteInput = document.getElementById('delete-pages');
|
||||||
|
if (deleteInput) deleteInput.value = '';
|
||||||
|
const reorderInput = document.getElementById('reorder-pages');
|
||||||
|
if (reorderInput) reorderInput.value = '';
|
||||||
|
|
||||||
|
grid.innerHTML = '<div class="loading-thumbnails">Loading page thumbnails...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfjs = getPdfjsLib();
|
||||||
|
const loadingTask = pdfjs.getDocument(filePath);
|
||||||
|
pdfDocInstance = await loadingTask.promise;
|
||||||
|
|
||||||
|
pdfPreviewState.filePath = filePath;
|
||||||
|
pdfPreviewState.pages = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= pdfDocInstance.numPages; i++) {
|
||||||
|
pdfPreviewState.pages.push({
|
||||||
|
originalPageNum: i,
|
||||||
|
rotation: 0,
|
||||||
|
isDeleted: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderThumbnailGrid();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading PDF thumbnails:', err);
|
||||||
|
grid.innerHTML = '<div class="thumbnail-error">Failed to load PDF thumbnails.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThumbnailGrid() {
|
||||||
|
const grid = document.getElementById('pdf-thumbnail-grid');
|
||||||
|
if (!grid || !pdfDocInstance) return;
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
pdfPreviewState.pages.forEach((pageInfo, index) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'pdf-thumbnail-card';
|
||||||
|
if (pageInfo.isDeleted) {
|
||||||
|
card.classList.add('marked-delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'pdf-thumbnail-header';
|
||||||
|
heading.innerHTML = `<span class="badge">Page ${pageInfo.originalPageNum}</span>`;
|
||||||
|
|
||||||
|
const canvasContainer = document.createElement('div');
|
||||||
|
canvasContainer.className = 'canvas-container';
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'pdf-thumbnail-canvas';
|
||||||
|
if (pageInfo.rotation > 0) {
|
||||||
|
canvas.classList.add('rot' + pageInfo.rotation);
|
||||||
|
}
|
||||||
|
canvasContainer.appendChild(canvas);
|
||||||
|
|
||||||
|
// Render thumbnail canvas
|
||||||
|
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
|
||||||
|
|
||||||
|
const controlsRow = document.createElement('div');
|
||||||
|
controlsRow.className = 'pdf-thumbnail-controls';
|
||||||
|
|
||||||
|
// Rotate Button
|
||||||
|
const rotateBtn = document.createElement('button');
|
||||||
|
rotateBtn.type = 'button';
|
||||||
|
rotateBtn.className = 'btn-rotate-thumbnail';
|
||||||
|
rotateBtn.innerHTML = '↻ Rotate 90°';
|
||||||
|
rotateBtn.addEventListener('click', () => {
|
||||||
|
pageInfo.rotation = (pageInfo.rotation + 90) % 360;
|
||||||
|
canvas.className = 'pdf-thumbnail-canvas';
|
||||||
|
if (pageInfo.rotation > 0) {
|
||||||
|
canvas.classList.add('rot' + pageInfo.rotation);
|
||||||
|
}
|
||||||
|
renderThumbnail(pdfDocInstance, pageInfo.originalPageNum, canvas, pageInfo.rotation);
|
||||||
|
syncRotateInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Checkbox
|
||||||
|
const deleteLabel = document.createElement('label');
|
||||||
|
deleteLabel.className = 'delete-thumbnail-checkbox';
|
||||||
|
|
||||||
|
const deleteChk = document.createElement('input');
|
||||||
|
deleteChk.type = 'checkbox';
|
||||||
|
deleteChk.checked = pageInfo.isDeleted;
|
||||||
|
deleteChk.addEventListener('change', (e) => {
|
||||||
|
pageInfo.isDeleted = e.target.checked;
|
||||||
|
card.classList.toggle('marked-delete', pageInfo.isDeleted);
|
||||||
|
syncDeleteInput();
|
||||||
|
});
|
||||||
|
deleteLabel.appendChild(deleteChk);
|
||||||
|
deleteLabel.appendChild(document.createTextNode(' Delete'));
|
||||||
|
|
||||||
|
// Reorder buttons
|
||||||
|
const reorderDiv = document.createElement('div');
|
||||||
|
reorderDiv.className = 'pdf-thumbnail-reorder';
|
||||||
|
|
||||||
|
const leftBtn = document.createElement('button');
|
||||||
|
leftBtn.type = 'button';
|
||||||
|
leftBtn.className = 'btn-reorder-left';
|
||||||
|
leftBtn.innerHTML = '◀';
|
||||||
|
leftBtn.disabled = (index === 0);
|
||||||
|
leftBtn.addEventListener('click', () => {
|
||||||
|
const temp = pdfPreviewState.pages[index];
|
||||||
|
pdfPreviewState.pages[index] = pdfPreviewState.pages[index - 1];
|
||||||
|
pdfPreviewState.pages[index - 1] = temp;
|
||||||
|
renderThumbnailGrid();
|
||||||
|
syncReorderInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightBtn = document.createElement('button');
|
||||||
|
rightBtn.type = 'button';
|
||||||
|
rightBtn.className = 'btn-reorder-right';
|
||||||
|
rightBtn.innerHTML = '▶';
|
||||||
|
rightBtn.disabled = (index === pdfPreviewState.pages.length - 1);
|
||||||
|
rightBtn.addEventListener('click', () => {
|
||||||
|
const temp = pdfPreviewState.pages[index];
|
||||||
|
pdfPreviewState.pages[index] = pdfPreviewState.pages[index + 1];
|
||||||
|
pdfPreviewState.pages[index + 1] = temp;
|
||||||
|
renderThumbnailGrid();
|
||||||
|
syncReorderInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
reorderDiv.appendChild(leftBtn);
|
||||||
|
reorderDiv.appendChild(rightBtn);
|
||||||
|
|
||||||
|
controlsRow.appendChild(rotateBtn);
|
||||||
|
controlsRow.appendChild(deleteLabel);
|
||||||
|
controlsRow.appendChild(reorderDiv);
|
||||||
|
|
||||||
|
card.appendChild(heading);
|
||||||
|
card.appendChild(canvasContainer);
|
||||||
|
card.appendChild(controlsRow);
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderThumbnail(pdfDoc, originalPageNum, canvasElement, rotation) {
|
||||||
|
try {
|
||||||
|
const page = await pdfDoc.getPage(originalPageNum);
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
|
||||||
|
// Render at a fixed width of 100px
|
||||||
|
const unscaledViewport = page.getViewport({ scale: 1, rotation: 0 });
|
||||||
|
const scale = 100 / unscaledViewport.width;
|
||||||
|
const viewport = page.getViewport({ scale: scale, rotation: 0 });
|
||||||
|
|
||||||
|
canvasElement.width = viewport.width;
|
||||||
|
canvasElement.height = viewport.height;
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport
|
||||||
|
}).promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error rendering page canvas:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRotateInput() {
|
||||||
|
const rotatedPages = pdfPreviewState.pages
|
||||||
|
.filter(p => p.rotation > 0)
|
||||||
|
.map(p => p.originalPageNum);
|
||||||
|
|
||||||
|
const rotateInput = document.getElementById('rotate-pages');
|
||||||
|
if (rotateInput) {
|
||||||
|
rotateInput.value = rotatedPages.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDeleteInput() {
|
||||||
|
const deletedPages = pdfPreviewState.pages
|
||||||
|
.filter(p => p.isDeleted)
|
||||||
|
.map(p => p.originalPageNum);
|
||||||
|
|
||||||
|
const deleteInput = document.getElementById('delete-pages');
|
||||||
|
if (deleteInput) {
|
||||||
|
deleteInput.value = deletedPages.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReorderInput() {
|
||||||
|
const order = pdfPreviewState.pages.map(p => p.originalPageNum);
|
||||||
|
const reorderInput = document.getElementById('reorder-pages');
|
||||||
|
if (reorderInput) {
|
||||||
|
reorderInput.value = order.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppShell } from './components/layout/AppShell';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { EditorState, Compartment } from '@codemirror/state';
|
||||||
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
|
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { lightTheme, lightHighlight } from './themes/light';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bufferId: string;
|
||||||
|
initialContent: string;
|
||||||
|
onChange?: (content: string) => void;
|
||||||
|
onCursorChange?: (line: number, column: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartment = useRef(new Compartment());
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const updateContent = useEditorStore((s) => s.updateContent);
|
||||||
|
const setCursor = useEditorStore((s) => s.setCursor);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: initialContent,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSelectionMatches(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
markdown({ base: markdownLanguage, codeLanguages: [] }),
|
||||||
|
autocompletion(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]),
|
||||||
|
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((v) => {
|
||||||
|
if (v.docChanged) {
|
||||||
|
const content = v.state.doc.toString();
|
||||||
|
updateContent(bufferId, content);
|
||||||
|
onChange?.(content);
|
||||||
|
}
|
||||||
|
if (v.selectionSet || v.docChanged) {
|
||||||
|
const head = v.state.selection.main.head;
|
||||||
|
const line = v.state.doc.lineAt(head);
|
||||||
|
const lineNo = line.number;
|
||||||
|
const col = head - line.from + 1;
|
||||||
|
setCursor(bufferId, lineNo, col);
|
||||||
|
onCursorChange?.(lineNo, col);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const view = new EditorView({ state, parent: ref.current });
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [bufferId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: themeCompartment.current.reconfigure(
|
||||||
|
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-full overflow-hidden" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
|
||||||
|
export function EditorPane() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (buf) setPreviewSource(buf.content);
|
||||||
|
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
||||||
|
|
||||||
|
if (!buf) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center bg-background text-muted-foreground">
|
||||||
|
<p>No file open. Use File → Open to start.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
key={buf.id}
|
||||||
|
bufferId={buf.id}
|
||||||
|
initialContent={buf.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#0d0b09',
|
||||||
|
cursor: '#e5461f',
|
||||||
|
selection: 'rgba(229, 70, 31, 0.15)',
|
||||||
|
gutterBackground: '#fafbfc',
|
||||||
|
gutterForeground: '#7a7878',
|
||||||
|
lineHighlight: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lightTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
'&': {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
color: colors.foreground,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
caretColor: colors.cursor,
|
||||||
|
fontFamily: 'JetBrains Mono, Fira Code, monospace',
|
||||||
|
fontSize: '13.5px',
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': { borderLeftColor: colors.cursor },
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: colors.selection,
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: colors.gutterBackground,
|
||||||
|
color: colors.gutterForeground,
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
'.cm-activeLine': { backgroundColor: colors.lineHighlight },
|
||||||
|
'.cm-activeLineGutter': { backgroundColor: 'transparent', color: '#e5461f' },
|
||||||
|
},
|
||||||
|
{ dark: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.heading1, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading2, color: '#0d0b09', fontWeight: '700' },
|
||||||
|
{ tag: t.heading3, color: '#464646', fontWeight: '600' },
|
||||||
|
{ tag: t.link, color: '#e5461f', textDecoration: 'underline' },
|
||||||
|
{ tag: t.url, color: '#e5461f' },
|
||||||
|
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||||
|
{ tag: t.strong, fontWeight: '700' },
|
||||||
|
{ tag: t.monospace, color: '#c93a18' },
|
||||||
|
{ tag: t.list, color: '#0ea5e9' },
|
||||||
|
{ tag: t.quote, color: '#7a7878', fontStyle: 'italic' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const lightHighlight = syntaxHighlighting(highlightStyle);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { PanelLeft, PanelRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="h-7 w-7 rounded-md bg-gradient-to-br from-brand to-brand-dark shadow-[var(--shadow-glow-brand)]"
|
||||||
|
aria-label="MarkdownConverter logo"
|
||||||
|
/>
|
||||||
|
<h1 className="font-display text-lg font-bold tracking-tight">MarkdownConverter</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle preview"
|
||||||
|
aria-pressed={previewVisible}
|
||||||
|
onClick={togglePreview}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { AppHeader } from './AppHeader';
|
||||||
|
import { TabBar } from './TabBar';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||||
|
<AppHeader />
|
||||||
|
<TabBar />
|
||||||
|
<Toolbar />
|
||||||
|
<Breadcrumb />
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })}
|
||||||
|
>
|
||||||
|
{sidebarVisible && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||||
|
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||||
|
File tree placeholder
|
||||||
|
</aside>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ResizablePanel defaultSize={previewVisible ? paneSizes.editor : 100} minSize={20}>
|
||||||
|
<EditorPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
{previewVisible && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={paneSizes.preview} minSize={20}>
|
||||||
|
<PreviewPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</main>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function Breadcrumb() {
|
||||||
|
return (
|
||||||
|
<nav aria-label="File path" className="flex h-7 items-center border-b border-border bg-card/10 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No file selected</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.trim().length === 0 ? 0 : text.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar() {
|
||||||
|
const { buffers, activeId } = useEditorStore();
|
||||||
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
|
const wordCount = buf ? countWords(buf.content) : 0;
|
||||||
|
const cursor = buf?.cursor ?? { line: 1, column: 1 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="flex h-7 items-center justify-between border-t border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>{wordCount} words</span>
|
||||||
|
<span>UTF-8</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span>Ln {cursor.line}, Col {cursor.column}</span>
|
||||||
|
<span>Markdown</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function TabBar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No files open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderMarkdown } from '@/lib/markdown';
|
||||||
|
import { MermaidLazy } from './MermaidLazy';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
|
function extractMermaidCodes(source: string): string[] {
|
||||||
|
const codes: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = MERMAID_RE.exec(source)) !== null) {
|
||||||
|
codes.push(match[1].trim());
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ source }: Props) {
|
||||||
|
const html = renderMarkdown(source);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [mermaidCodes, setMermaidCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Re-derive mermaid codes from the original source. The renderMarkdown function
|
||||||
|
// replaces mermaid blocks with placeholders (data-mermaid-source="${idx}"), but
|
||||||
|
// it does not return the actual codes. Rather than thread them through the API,
|
||||||
|
// we re-extract from the source. Source is small (single buffer content) so this
|
||||||
|
// cost is negligible.
|
||||||
|
useEffect(() => {
|
||||||
|
setMermaidCodes(extractMermaidCodes(source));
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
{mermaidCodes.map((code, i) => (
|
||||||
|
<div key={i} className="my-4">
|
||||||
|
<MermaidLazy code={code} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mermaidModule: typeof import('mermaid').default | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
async function getMermaid() {
|
||||||
|
if (!mermaidModule) {
|
||||||
|
const mod = await import('mermaid');
|
||||||
|
mermaidModule = mod.default;
|
||||||
|
}
|
||||||
|
if (!initialized) {
|
||||||
|
mermaidModule.initialize({ startOnLoad: false, theme: 'default' });
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return mermaidModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MermaidLazy({ code }: Props) {
|
||||||
|
const [svg, setSvg] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getMermaid()
|
||||||
|
.then((m) => m.render(idRef.current, code))
|
||||||
|
.then((rendered) => {
|
||||||
|
if (!cancelled) setSvg(rendered);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
if (error) return <div className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">{error}</div>;
|
||||||
|
if (!svg) return <div className="text-xs text-muted-foreground">Loading diagram…</div>;
|
||||||
|
return <div data-testid="mermaid-output" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
import { useScrollSync } from '@/hooks/use-scroll-sync';
|
||||||
|
|
||||||
|
export function PreviewPane() {
|
||||||
|
const { source, setScrollRatio } = usePreviewStore();
|
||||||
|
const { handlePreviewScroll } = useScrollSync({ onPreviewScroll: setScrollRatio });
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<p>Nothing to preview. Start typing in the editor.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto bg-card/10" onScroll={handlePreviewScroll}>
|
||||||
|
<MarkdownRenderer source={source} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Toggle theme" className="opacity-0">
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanelGroup.displayName = "ResizablePanelGroup"
|
||||||
|
|
||||||
|
const ResizablePanel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ResizablePrimitive.Panel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Panel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ResizablePrimitive.Panel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ResizablePanel.displayName = "ResizablePanel"
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<typeof ResizablePrimitive.Separator> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
ResizableHandle.displayName = "ResizableHandle"
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
onEditorScroll?: (ratio: number) => void;
|
||||||
|
onPreviewScroll?: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollSync(opts: Options) {
|
||||||
|
const FRAME_MS = 1000 / 60;
|
||||||
|
const lastTick = useRef(-FRAME_MS);
|
||||||
|
|
||||||
|
const handleEditorScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||||
|
const target = evt.currentTarget;
|
||||||
|
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTick.current < FRAME_MS) return;
|
||||||
|
lastTick.current = now;
|
||||||
|
opts.onEditorScroll?.(ratio);
|
||||||
|
}, [opts]);
|
||||||
|
|
||||||
|
const handlePreviewScroll = useCallback((evt: React.UIEvent<HTMLElement>) => {
|
||||||
|
const target = evt.currentTarget;
|
||||||
|
const ratio = target.scrollTop / Math.max(target.scrollHeight - target.clientHeight, 1);
|
||||||
|
opts.onPreviewScroll?.(ratio);
|
||||||
|
}, [opts]);
|
||||||
|
|
||||||
|
return { handleEditorScroll, handlePreviewScroll };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MarkdownConverter</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type {
|
||||||
|
IpcResult,
|
||||||
|
FileResult,
|
||||||
|
FileEntry,
|
||||||
|
PdfOptions,
|
||||||
|
DocxOptions,
|
||||||
|
HtmlOptions,
|
||||||
|
ExportResult,
|
||||||
|
BatchItem,
|
||||||
|
BatchOptions,
|
||||||
|
BatchResult,
|
||||||
|
} from '@/types/ipc';
|
||||||
|
|
||||||
|
type ChannelMissing = { code: 'CHANNEL_MISSING'; message: string };
|
||||||
|
|
||||||
|
function wrap<T>(fn: () => Promise<T>): Promise<IpcResult<T | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fn().then(
|
||||||
|
(data) => ({ ok: true as const, data }),
|
||||||
|
(err: Error) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: { code: err.name || 'IPC_ERROR', message: err.message || String(err) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCall<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
channel: string,
|
||||||
|
method: string,
|
||||||
|
...args: Parameters<T>
|
||||||
|
): Promise<IpcResult<Awaited<ReturnType<T>> | ChannelMissing>> {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'NO_BRIDGE', message: 'window.electronAPI is unavailable' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const target = (window.electronAPI as any)[channel]?.[method];
|
||||||
|
if (!target) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Missing channel: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof target !== 'function') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'CHANNEL_MISSING', message: `Not a function: ${channel}.${method}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wrap(() => target(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ipc = {
|
||||||
|
file: {
|
||||||
|
open: (): Promise<IpcResult<FileResult | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'open'),
|
||||||
|
read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'read', path),
|
||||||
|
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'write', path, content),
|
||||||
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'list', dir),
|
||||||
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
return window.electronAPI.file.onChange(cb);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
pdf: (opts: PdfOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'pdf', opts),
|
||||||
|
docx: (opts: DocxOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'docx', opts),
|
||||||
|
html: (opts: HtmlOptions): Promise<IpcResult<ExportResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'html', opts),
|
||||||
|
batch: (items: BatchItem[], opts: BatchOptions): Promise<IpcResult<BatchResult | ChannelMissing>> =>
|
||||||
|
safeCall('export', 'batch', items, opts),
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: (): Promise<IpcResult<string | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'getVersion'),
|
||||||
|
openExternal: (url: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'openExternal', url),
|
||||||
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
|
safeCall('app', 'showItemInFolder', path),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MERMAID_RE = /```mermaid\n([\s\S]*?)```/g;
|
||||||
|
|
||||||
|
export function renderMarkdown(source: string): string {
|
||||||
|
// Mark mermaid blocks with a placeholder we can replace client-side.
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
const withPlaceholders = source.replace(MERMAID_RE, (_m, code) => {
|
||||||
|
const idx = placeholders.length;
|
||||||
|
placeholders.push(code.trim());
|
||||||
|
return `<div class="mermaid-block" data-mermaid-source="${idx}"></div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawHtml = marked.parse(withPlaceholders, { async: false }) as string;
|
||||||
|
const clean = DOMPurify.sanitize(rawHtml, {
|
||||||
|
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'data-mermaid-source', 'data-language', 'id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The sanitized HTML still has placeholders; we leave the actual mermaid
|
||||||
|
// rendering to the React layer (MermaidLazy component) so the heavy
|
||||||
|
// mermaid library only loads when needed.
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Transition, Variants } from 'motion/react';
|
||||||
|
|
||||||
|
export const fadeIn: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const slideInRight: Variants = {
|
||||||
|
x: '100%',
|
||||||
|
initial: { x: '100%', opacity: 0 },
|
||||||
|
animate: { x: 0, opacity: 1, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] } },
|
||||||
|
exit: { x: '100%', opacity: 0, transition: { duration: 0.2, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modalPop: Variants = {
|
||||||
|
scale: 0.96,
|
||||||
|
opacity: 0,
|
||||||
|
initial: { scale: 0.96, opacity: 0 },
|
||||||
|
animate: { scale: 1, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' } },
|
||||||
|
exit: { scale: 0.96, opacity: 0, transition: { duration: 0.15, ease: 'easeIn' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toastSpring: Transition = {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sidebarToggle: Transition = {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
width: {
|
||||||
|
duration: 0.25,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tabSwitch: Transition = {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'easeOut',
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import { ThemeProvider } from './components/theme-provider';
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class" enableSystem>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export interface PaneSizes {
|
||||||
|
sidebar: number;
|
||||||
|
editor: number;
|
||||||
|
preview: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
sidebarVisible: boolean;
|
||||||
|
previewVisible: boolean;
|
||||||
|
zenMode: boolean;
|
||||||
|
paneSizes: PaneSizes;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
togglePreview: () => void;
|
||||||
|
setZenMode: (value: boolean) => void;
|
||||||
|
setPaneSizes: (sizes: PaneSizes) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||||
|
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||||
|
setZenMode: (value) => set({ zenMode: value }),
|
||||||
|
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||||
|
}),
|
||||||
|
{ name: 'mc-app-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface Buffer {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
dirty: boolean;
|
||||||
|
cursor?: { line: number; column: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorState {
|
||||||
|
buffers: Map<string, Buffer>;
|
||||||
|
activeId: string | null;
|
||||||
|
openBuffer: (id: string, path: string, content: string) => void;
|
||||||
|
updateContent: (id: string, content: string) => void;
|
||||||
|
markSaved: (id: string) => void;
|
||||||
|
setCursor: (id: string, line: number, column: number) => void;
|
||||||
|
closeBuffer: (id: string) => void;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
immer((set) => ({
|
||||||
|
buffers: new Map(),
|
||||||
|
activeId: null,
|
||||||
|
openBuffer: (id, path, content) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.set(id, { id, path, content, dirty: false });
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
updateContent: (id, content) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) {
|
||||||
|
buf.content = content;
|
||||||
|
buf.dirty = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
markSaved: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.dirty = false;
|
||||||
|
}),
|
||||||
|
setCursor: (id, line, column) =>
|
||||||
|
set((s) => {
|
||||||
|
const buf = s.buffers.get(id);
|
||||||
|
if (buf) buf.cursor = { line, column };
|
||||||
|
}),
|
||||||
|
closeBuffer: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.buffers.delete(id);
|
||||||
|
if (s.activeId === id) s.activeId = null;
|
||||||
|
}),
|
||||||
|
setActive: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
s.activeId = id;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface PreviewState {
|
||||||
|
source: string;
|
||||||
|
scrollRatio: number;
|
||||||
|
setSource: (s: string) => void;
|
||||||
|
setScrollRatio: (r: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pending: string = '';
|
||||||
|
|
||||||
|
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||||
|
source: '',
|
||||||
|
scrollRatio: 0,
|
||||||
|
setSource: (s) => {
|
||||||
|
pending = s;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
set({ source: pending });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
setScrollRatio: (r) => set({ scrollRatio: r }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 9%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 96%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96%;
|
||||||
|
--muted-foreground: 0 0% 45%;
|
||||||
|
--accent: 0 0% 96%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 90%;
|
||||||
|
--input: 0 0% 90%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 30 14% 4%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 30 14% 6%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 30 14% 6%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 11 79% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 30 6% 15%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 30 6% 15%;
|
||||||
|
--muted-foreground: 0 0% 63%;
|
||||||
|
--accent: 30 6% 15%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62% 30%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 30 6% 15%;
|
||||||
|
--input: 30 6% 15%;
|
||||||
|
--ring: 11 79% 51%;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-md: 0 4px 12px rgba(13, 11, 9, 0.08), 0 0 0 1px rgba(13, 11, 9, 0.04);
|
||||||
|
--shadow-lg: 0 12px 32px rgba(13, 11, 9, 0.12), 0 0 0 1px rgba(13, 11, 9, 0.06);
|
||||||
|
--shadow-glow-brand: 0 0 24px rgba(229, 70, 31, 0.25);
|
||||||
|
--glass-bg-light: rgba(255, 255, 255, 0.72);
|
||||||
|
--glass-bg-dark: rgba(13, 11, 9, 0.72);
|
||||||
|
--glass-border-light: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border-dark: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CodeMirror integration */
|
||||||
|
.cm-editor {
|
||||||
|
@apply h-full;
|
||||||
|
}
|
||||||
|
.cm-editor .cm-scroller {
|
||||||
|
@apply font-mono text-sm;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// Mock window.electronAPI for tests
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||||
|
window.electronAPI = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock matchMedia for next-themes (jsdom doesn't have it)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
|
||||||
|
}
|
||||||
Vendored
+90
@@ -0,0 +1,90 @@
|
|||||||
|
export interface ElectronAPI {
|
||||||
|
// App info
|
||||||
|
getAppVersion: () => Promise<string>;
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
||||||
|
saveFile: (data: { path: string; content: string }) => Promise<void>;
|
||||||
|
readFile: (path: string) => Promise<string>;
|
||||||
|
setCurrentFile: (path: string | null) => void;
|
||||||
|
|
||||||
|
// Export
|
||||||
|
exportDocument: (format: string, options?: Record<string, any>) => Promise<void>;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
getTheme: () => void;
|
||||||
|
onThemeChanged: (callback: (theme: string) => void) => () => void;
|
||||||
|
|
||||||
|
// File events
|
||||||
|
onFileOpened: (callback: (data: { path: string; content: string }) => void) => () => void;
|
||||||
|
onFileNew: (callback: () => void) => () => void;
|
||||||
|
onFileSave: (callback: () => void) => () => void;
|
||||||
|
|
||||||
|
// Conversion status
|
||||||
|
onConversionStatus: (callback: (message: string) => void) => () => void;
|
||||||
|
onConversionComplete: (callback: (data: { format: string; outputPath: string }) => void) => () => void;
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
showExportDialog: (format: string) => void;
|
||||||
|
showBatchDialog: () => void;
|
||||||
|
showUniversalConverterDialog: () => void;
|
||||||
|
showTableGenerator: () => void;
|
||||||
|
showPdfEditorDialog: () => void;
|
||||||
|
showHeaderFooterDialog: () => void;
|
||||||
|
showFieldPickerDialog: () => void;
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: (key: string) => Promise<any>;
|
||||||
|
setSettings: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Plugin settings
|
||||||
|
getPluginSetting: (key: string) => Promise<any>;
|
||||||
|
setPluginSetting: (key: string, value: any) => Promise<void>;
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
toggleBottomPanel: () => void;
|
||||||
|
|
||||||
|
// Print
|
||||||
|
doPrint: (options?: any) => void;
|
||||||
|
|
||||||
|
// PDF
|
||||||
|
openPdfViewer: (filePath: string) => void;
|
||||||
|
processPdfOperation: (data: any) => void;
|
||||||
|
|
||||||
|
// Images
|
||||||
|
selectFolder: () => Promise<string | null>;
|
||||||
|
savePastedImage: (data: { base64: string; ext: string }) => Promise<{ relativePath: string } | null>;
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
loadTemplate: (file: string) => Promise<string>;
|
||||||
|
getSnippets: () => Promise<any[]>;
|
||||||
|
saveSnippet: (snippet: any) => Promise<void>;
|
||||||
|
deleteSnippet: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
gitStatus: () => Promise<any>;
|
||||||
|
gitDiff: (file: string) => Promise<any>;
|
||||||
|
gitStage: (files: string[]) => Promise<void>;
|
||||||
|
gitCommit: (message: string) => Promise<void>;
|
||||||
|
gitLog: () => Promise<any[]>;
|
||||||
|
|
||||||
|
// Directory
|
||||||
|
listDirectory: (dir: string) => Promise<any[] | null>;
|
||||||
|
openFilePath: (path: string) => void;
|
||||||
|
|
||||||
|
// Custom CSS
|
||||||
|
selectCustomCSS: () => Promise<string | null>;
|
||||||
|
loadCustomCSS: () => void;
|
||||||
|
clearCustomCSS: () => void;
|
||||||
|
|
||||||
|
// Generic invoke/on
|
||||||
|
invoke: (channel: string, data?: any) => Promise<any>;
|
||||||
|
on: (channel: string, callback: (...args: any[]) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type IpcResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResult {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
format?: 'letter' | 'a4' | 'legal';
|
||||||
|
margins?: { top: number; right: number; bottom: number; left: number };
|
||||||
|
toc?: boolean;
|
||||||
|
embedFonts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocxOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
template?: string;
|
||||||
|
referenceDoc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HtmlOptions {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
standalone?: boolean;
|
||||||
|
highlightStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportResult {
|
||||||
|
outputPath: string;
|
||||||
|
bytes: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchItem {
|
||||||
|
inputPath: string;
|
||||||
|
outputPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
format: 'pdf' | 'docx' | 'html' | 'png';
|
||||||
|
concurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchResult {
|
||||||
|
total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ item: BatchItem; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
@@ -2372,3 +2372,254 @@ body[class*="dark"] .breadcrumb-bar {
|
|||||||
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);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"] .analytics-modal { background:var(--gray-800,#1f2937); }
|
||||||
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ModalManager - Unified modal system with accessibility support
|
* ModalManager - Unified modal system with accessibility support
|
||||||
* @version 4.1.0
|
* @version 4.4.2
|
||||||
*/
|
*/
|
||||||
class ModalManager {
|
class ModalManager {
|
||||||
#modal;
|
#modal;
|
||||||
@@ -237,8 +237,8 @@ class ModalManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use in renderer
|
// Export for use in renderer - avoid duplicate declaration
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined' && !window.ModalManager) {
|
||||||
window.ModalManager = ModalManager;
|
window.ModalManager = ModalManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
function createWelcomeContent(recentFiles = []) {
|
function createWelcomeContent(recentFiles = [], appVersion = '') {
|
||||||
const recentHtml = recentFiles.length
|
const recentHtml = recentFiles.length
|
||||||
? recentFiles.map(f => {
|
? recentFiles.map(f => {
|
||||||
const name = f.split(/[/\\]/).pop();
|
const name = f.split(/[/\\]/).pop();
|
||||||
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
|
|||||||
<div class="welcome-container">
|
<div class="welcome-container">
|
||||||
<div class="welcome-hero">
|
<div class="welcome-hero">
|
||||||
<h1 class="welcome-title">MarkdownConverter</h1>
|
<h1 class="welcome-title">MarkdownConverter</h1>
|
||||||
<p class="welcome-version">Version 4.1.0</p>
|
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
|
||||||
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -655,7 +655,7 @@ class WordTemplateExporter {
|
|||||||
*/
|
*/
|
||||||
parseInlineFormatting(text) {
|
parseInlineFormatting(text) {
|
||||||
let xml = '';
|
let xml = '';
|
||||||
let pos = 0;
|
const pos = 0;
|
||||||
|
|
||||||
// Patterns for inline formatting
|
// Patterns for inline formatting
|
||||||
const patterns = [
|
const patterns = [
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ class ZenMode {
|
|||||||
_applyTypewriterBehavior(view) {
|
_applyTypewriterBehavior(view) {
|
||||||
// We use a custom update listener that scrolls the cursor to center.
|
// We use a custom update listener that scrolls the cursor to center.
|
||||||
// This is stored on the instance so we can clean it up.
|
// This is stored on the instance so we can clean it up.
|
||||||
let scrollTimeout = null;
|
const scrollTimeout = null;
|
||||||
|
|
||||||
const scrollFn = () => {
|
const scrollFn = () => {
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const animate = require('tailwindcss-animate')
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./src/renderer/index.html',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: { '2xl': '1400px' },
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
DEFAULT: '#e5461f',
|
||||||
|
dark: '#c93a18',
|
||||||
|
light: '#ff6b47',
|
||||||
|
},
|
||||||
|
success: '#1a7a56',
|
||||||
|
warning: 'hsl(45 93% 47%)',
|
||||||
|
danger: 'hsl(0 84% 60%)',
|
||||||
|
info: 'hsl(199 89% 48%)',
|
||||||
|
warm: {
|
||||||
|
50: '#fafbfc', 100: '#f5f5f5', 200: '#e3e3e3', 300: '#d1d1d1',
|
||||||
|
400: '#b0b0b0', 500: '#9a9696', 600: '#7a7878', 700: '#5a5858',
|
||||||
|
800: '#4e4e4e', 900: '#464646', 950: '#0d0b09',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
|
||||||
|
display: ['"Barlow Condensed"', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', '"SF Mono"', 'Consolas', 'monospace'],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
||||||
|
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [animate],
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { CodeMirrorEditor } from '@/components/editor/CodeMirrorEditor';
|
||||||
|
|
||||||
|
vi.mock('@/components/editor/CodeMirrorEditor', () => ({
|
||||||
|
CodeMirrorEditor: ({ initialContent }: { initialContent: string }) => (
|
||||||
|
<div data-testid="codemirror-mock">{initialContent}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('EditorPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when no buffer is open', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the editor when a buffer is open', () => {
|
||||||
|
useEditorStore.getState().openBuffer('b1', '/x.md', '# hello');
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<EditorPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('codemirror-mock')).toHaveTextContent('# hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
describe('AppHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the app title', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sidebar when sidebar button clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom environment
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction, onLayout }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize, minSize, maxSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize} data-min={minSize} data-max={maxSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppShell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all shell surfaces when sidebar and preview are visible', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/markdownconverter/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file selected/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sidebar when sidebarVisible is false', () => {
|
||||||
|
useAppStore.setState({ sidebarVisible: false });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppShell />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/file tree placeholder/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBar } from '@/components/layout/StatusBar';
|
||||||
|
|
||||||
|
describe('StatusBar', () => {
|
||||||
|
it('renders zero word count when no file is open', () => {
|
||||||
|
render(<StatusBar />);
|
||||||
|
expect(screen.getByText(/0 words/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TabBar } from '@/components/layout/TabBar';
|
||||||
|
|
||||||
|
describe('TabBar', () => {
|
||||||
|
it('renders an empty state when no tabs are open', () => {
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Toolbar } from '@/components/layout/Toolbar';
|
||||||
|
|
||||||
|
describe('Toolbar', () => {
|
||||||
|
it('renders formatting buttons', () => {
|
||||||
|
render(<Toolbar />);
|
||||||
|
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MermaidLazy } from '@/components/preview/MermaidLazy';
|
||||||
|
|
||||||
|
vi.mock('mermaid', () => ({
|
||||||
|
default: {
|
||||||
|
initialize: vi.fn(),
|
||||||
|
render: vi.fn().mockResolvedValue('<svg></svg>'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('MermaidLazy', () => {
|
||||||
|
it('renders an svg after mermaid resolves', async () => {
|
||||||
|
render(<MermaidLazy code="graph TD; A-->B" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mermaid-output').innerHTML).toContain('<svg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error message if mermaid throws', async () => {
|
||||||
|
const mermaid = (await import('mermaid')).default as any;
|
||||||
|
mermaid.render.mockRejectedValueOnce(new Error('mermaid failed'));
|
||||||
|
render(<MermaidLazy code="bad code" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/mermaid failed/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
|
||||||
|
describe('PreviewPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
usePreviewStore.setState({ scrollRatio: 0, source: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no source', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<PreviewPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/nothing to preview/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown when source is set', () => {
|
||||||
|
usePreviewStore.setState({ source: '# Hello' });
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<PreviewPane />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: /hello/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove('dark', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles between light and dark when clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="light" attribute="class">
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toHaveAccessibleName(/toggle theme/i);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when clicked', async () => {
|
||||||
|
let count = 0;
|
||||||
|
render(<Button onClick={() => count++}>+</Button>);
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the brand variant with primary styling', () => {
|
||||||
|
render(<Button variant="default">Brand</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.className).toContain('bg-primary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const { EventBus } = require('../src/plugins/event-bus');
|
||||||
|
|
||||||
|
describe('EventBus', () => {
|
||||||
|
let bus;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bus = new EventBus();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on/emit — listener receives payload', () => {
|
||||||
|
const received = [];
|
||||||
|
bus.on('document:saved', (payload) => received.push(payload));
|
||||||
|
bus.emit('document:saved', { filePath: '/test.md', tabId: 'tab1' });
|
||||||
|
expect(received).toEqual([{ filePath: '/test.md', tabId: 'tab1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on — ignores events with no listeners', () => {
|
||||||
|
expect(() => bus.emit('unknown:event', {})).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off — removes specific listener', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
bus.on('test:event', handler);
|
||||||
|
bus.off('test:event', handler);
|
||||||
|
bus.emit('test:event', {});
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('off — removes all listeners for event when no handler given', () => {
|
||||||
|
const h1 = jest.fn();
|
||||||
|
const h2 = jest.fn();
|
||||||
|
bus.on('test:event', h1);
|
||||||
|
bus.on('test:event', h2);
|
||||||
|
bus.off('test:event');
|
||||||
|
bus.emit('test:event', {});
|
||||||
|
expect(h1).not.toHaveBeenCalled();
|
||||||
|
expect(h2).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasHandler — returns true when listener exists', () => {
|
||||||
|
bus.on('ai:analyze', () => {});
|
||||||
|
expect(bus.hasHandler('ai:analyze')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasHandler — returns false when no listener exists', () => {
|
||||||
|
expect(bus.hasHandler('ai:analyze')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handler errors are caught and logged, not thrown', () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
bus.on('bad:event', () => { throw new Error('boom'); });
|
||||||
|
expect(() => bus.emit('bad:event', {})).not.toThrow();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple listeners all receive the event', () => {
|
||||||
|
const results = [];
|
||||||
|
bus.on('multi:event', () => results.push('a'));
|
||||||
|
bus.on('multi:event', () => results.push('b'));
|
||||||
|
bus.emit('multi:event', {});
|
||||||
|
expect(results).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Git Operations Utilities Test Suite
|
||||||
|
* Tests for async patterns and error handling
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('GitOperations Utilities', () => {
|
||||||
|
describe('error handling patterns', () => {
|
||||||
|
it('should handle git errors gracefully', () => {
|
||||||
|
const errorResponse = { error: 'Not a git repository' };
|
||||||
|
|
||||||
|
expect(errorResponse).toHaveProperty('error');
|
||||||
|
expect(errorResponse.error).toContain('repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object on failure', () => {
|
||||||
|
const failureResult = { error: 'Failed to commit' };
|
||||||
|
|
||||||
|
expect(failureResult).toBeDefined();
|
||||||
|
expect(failureResult.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('async patterns', () => {
|
||||||
|
it('should handle async operations', async () => {
|
||||||
|
const asyncFn = async () => {
|
||||||
|
return { status: 'success' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await asyncFn();
|
||||||
|
expect(result).toHaveProperty('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle async errors', async () => {
|
||||||
|
const asyncFnWithError = async () => {
|
||||||
|
throw new Error('Git operation failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFnWithError();
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err) {
|
||||||
|
expect(err.message).toContain('Git');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('git operations', () => {
|
||||||
|
it('should document expected operations', () => {
|
||||||
|
const gitOps = [
|
||||||
|
'getStatus',
|
||||||
|
'stage',
|
||||||
|
'commit',
|
||||||
|
'log',
|
||||||
|
'diff',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'push',
|
||||||
|
'pull'
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(gitOps.length).toBeGreaterThan(0);
|
||||||
|
gitOps.forEach(op => {
|
||||||
|
expect(typeof op).toBe('string');
|
||||||
|
expect(op.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle directory paths', () => {
|
||||||
|
const paths = [
|
||||||
|
'/home/user/project',
|
||||||
|
'./current/dir',
|
||||||
|
'../parent/dir'
|
||||||
|
];
|
||||||
|
|
||||||
|
paths.forEach(pathStr => {
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
expect(pathStr.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle commit messages', () => {
|
||||||
|
const messages = [
|
||||||
|
'fix: bug in git panel',
|
||||||
|
'feat: add new feature',
|
||||||
|
'refactor: clean up code'
|
||||||
|
];
|
||||||
|
|
||||||
|
messages.forEach(msg => {
|
||||||
|
expect(typeof msg).toBe('string');
|
||||||
|
expect(msg.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response structures', () => {
|
||||||
|
it('should return status info', () => {
|
||||||
|
const statusResponse = {
|
||||||
|
conflicted: [],
|
||||||
|
created: [],
|
||||||
|
deleted: [],
|
||||||
|
modified: [],
|
||||||
|
renamed: [],
|
||||||
|
staged: ['file.md']
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(statusResponse).toHaveProperty('staged');
|
||||||
|
expect(Array.isArray(statusResponse.staged)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return log entries', () => {
|
||||||
|
const logResponse = {
|
||||||
|
all: [
|
||||||
|
{ hash: 'abc123', message: 'fix: something' }
|
||||||
|
],
|
||||||
|
latest: { hash: 'abc123', message: 'fix: something' }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(logResponse).toHaveProperty('all');
|
||||||
|
expect(Array.isArray(logResponse.all)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,75 @@ describe('Markdown Extensions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Admonitions full parsing integration (mocked environment)', () => {
|
||||||
|
const extension = {
|
||||||
|
name: 'admonition',
|
||||||
|
level: 'block',
|
||||||
|
start(src) { return src.match(/^:::(note|warning|tip|danger|info)/m)?.index; },
|
||||||
|
tokenizer(src) {
|
||||||
|
const match = src.match(/^:::(note|warning|tip|danger|info)\s*\n([\s\S]*?)^:::\s*$/m);
|
||||||
|
if (match && match.index === 0) {
|
||||||
|
const admonitionType = match[1];
|
||||||
|
const text = match[2].trim();
|
||||||
|
const tokens = [];
|
||||||
|
this.lexer.blockTokens(text, tokens);
|
||||||
|
return {
|
||||||
|
type: 'admonition',
|
||||||
|
raw: match[0],
|
||||||
|
admonitionType,
|
||||||
|
text,
|
||||||
|
tokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer(token) {
|
||||||
|
const icons = { note: 'ℹ', warning: '⚠', tip: '💡', danger: '🔴', info: 'ℹ' };
|
||||||
|
const icon = icons[token.admonitionType] || 'ℹ';
|
||||||
|
const inner = this.parser.parse(token.tokens || []);
|
||||||
|
return `<div class="admonition admonition-${token.admonitionType}">
|
||||||
|
<div class="admonition-title">${icon} ${token.admonitionType.charAt(0).toUpperCase() + token.admonitionType.slice(1)}</div>
|
||||||
|
<div class="admonition-content">${inner}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('tokenizer correctly extracts tokens and calls blockTokens', () => {
|
||||||
|
const src = ':::note\nThis is a note.\n:::';
|
||||||
|
const mockLexer = {
|
||||||
|
blockTokens: jest.fn((text, tokens) => {
|
||||||
|
tokens.push({ type: 'text', text });
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const context = { lexer: mockLexer };
|
||||||
|
const result = extension.tokenizer.call(context, src);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.type).toBe('admonition');
|
||||||
|
expect(result.admonitionType).toBe('note');
|
||||||
|
expect(result.text).toBe('This is a note.');
|
||||||
|
expect(mockLexer.blockTokens).toHaveBeenCalledWith('This is a note.', expect.any(Array));
|
||||||
|
expect(result.tokens).toEqual([{ type: 'text', text: 'This is a note.' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer correctly translates tokens to HTML', () => {
|
||||||
|
const token = {
|
||||||
|
type: 'admonition',
|
||||||
|
admonitionType: 'warning',
|
||||||
|
tokens: [{ type: 'text', text: 'Be careful!' }]
|
||||||
|
};
|
||||||
|
const mockParser = {
|
||||||
|
parse: jest.fn((tokens) => '<p>Be careful!</p>')
|
||||||
|
};
|
||||||
|
const context = { parser: mockParser };
|
||||||
|
const html = extension.renderer.call(context, token);
|
||||||
|
|
||||||
|
expect(html).toContain('admonition admonition-warning');
|
||||||
|
expect(html).toContain('⚠ Warning');
|
||||||
|
expect(html).toContain('<p>Be careful!</p>');
|
||||||
|
expect(mockParser.parse).toHaveBeenCalledWith(token.tokens);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PlantUML hex encoding', () => {
|
describe('PlantUML hex encoding', () => {
|
||||||
const plantumlEncode = (text) => {
|
const plantumlEncode = (text) => {
|
||||||
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
const hex = Array.from(Buffer.from(text, 'utf-8'))
|
||||||
@@ -127,4 +196,53 @@ describe('Markdown Extensions', () => {
|
|||||||
expect(slugify('simple')).toBe('simple');
|
expect(slugify('simple')).toBe('simple');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scopeCSS utility', () => {
|
||||||
|
const scopeCSS = (cssText, scopeSelector) => {
|
||||||
|
if (!cssText) return '';
|
||||||
|
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
|
||||||
|
const trimmed = selector.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('@') || trimmed.startsWith(':root') || trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%$/.test(trimmed)) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return scopeSelector + ' ' + trimmed + (separator || '');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
test('scopes standard tag selector', () => {
|
||||||
|
const css = 'h1 { color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe('.preview-content h1{ color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes multiple class selectors', () => {
|
||||||
|
const css = '.title, .content { font-family: sans-serif; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe('.preview-content .title,.preview-content .content{ font-family: sans-serif; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores @rules like @media', () => {
|
||||||
|
const css = '@media (max-width: 600px) { h1 { color: blue; } }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('@media (max-width: 600px)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores :root selector', () => {
|
||||||
|
const css = ':root { --color: red; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toBe(':root { --color: red; }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores keyframe percentages', () => {
|
||||||
|
const css = '0% { opacity: 0; } 100% { opacity: 1; }';
|
||||||
|
const scoped = scopeCSS(css, '.preview-content');
|
||||||
|
expect(scoped).toContain('0%');
|
||||||
|
expect(scoped).toContain('100%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty input', () => {
|
||||||
|
expect(scopeCSS('', '.preview-content')).toBe('');
|
||||||
|
expect(scopeCSS(null, '.preview-content')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 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 input = '1';
|
||||||
|
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 input = '1-3';
|
||||||
|
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 input = '1-2,4-5';
|
||||||
|
const pages = [0, 1, 3, 4]; // Expected result
|
||||||
|
|
||||||
|
expect(pages.length).toBe(4);
|
||||||
|
expect(pages).toEqual([0, 1, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort pages in ascending order', () => {
|
||||||
|
const unsorted = [2, 0, 3, 1];
|
||||||
|
const sorted = unsorted.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
expect(sorted).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hex color conversion', () => {
|
||||||
|
it('should validate hex color format', () => {
|
||||||
|
const validHex = '#FF5733';
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(validHex);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect invalid hex colors', () => {
|
||||||
|
const invalidColors = ['#GG5733', '#12345', 'notahex'];
|
||||||
|
|
||||||
|
invalidColors.forEach(color => {
|
||||||
|
const isValid = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(color);
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should handle empty input', () => {
|
||||||
|
const input = '';
|
||||||
|
expect(input.length).toBe(0);
|
||||||
|
// Should not process empty strings
|
||||||
|
expect(input === '').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid page numbers', () => {
|
||||||
|
const testPages = [
|
||||||
|
{ input: '0', isValid: false },
|
||||||
|
{ input: '-1', isValid: false },
|
||||||
|
{ input: 'abc', isValid: false },
|
||||||
|
{ input: '1', isValid: true },
|
||||||
|
{ input: '5', isValid: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
testPages.forEach(({ input, isValid }) => {
|
||||||
|
const num = parseInt(input);
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
// Non-numeric string
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else if (num < 1) {
|
||||||
|
// Zero or negative
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
} else {
|
||||||
|
// Positive number
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const { PluginAPI } = require('../src/plugins/plugin-api');
|
||||||
|
|
||||||
|
describe('PluginAPI', () => {
|
||||||
|
test('has default no-op lifecycle methods', () => {
|
||||||
|
const plugin = new PluginAPI();
|
||||||
|
expect(() => plugin.init({})).not.toThrow();
|
||||||
|
expect(() => plugin.activate()).not.toThrow();
|
||||||
|
expect(() => plugin.deactivate()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getManifest returns null by default', () => {
|
||||||
|
const plugin = new PluginAPI();
|
||||||
|
expect(plugin.getManifest()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subclass can override init', () => {
|
||||||
|
let called = false;
|
||||||
|
class MyPlugin extends PluginAPI {
|
||||||
|
init(context) {
|
||||||
|
called = true;
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const p = new MyPlugin();
|
||||||
|
p.init({ foo: 'bar' });
|
||||||
|
expect(called).toBe(true);
|
||||||
|
expect(p.context.foo).toBe('bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const { PluginContext } = require('../src/plugins/plugin-context');
|
||||||
|
const { EventBus } = require('../src/plugins/event-bus');
|
||||||
|
|
||||||
|
describe('PluginContext', () => {
|
||||||
|
let context;
|
||||||
|
let mockDeps;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeps = {
|
||||||
|
pluginId: 'test-plugin',
|
||||||
|
sidebar: { registerPanel: jest.fn() },
|
||||||
|
commands: { register: jest.fn() },
|
||||||
|
statusBar: { registerIndicator: jest.fn() },
|
||||||
|
eventBus: new EventBus(),
|
||||||
|
settings: { get: jest.fn(), set: jest.fn(), onChanged: jest.fn() },
|
||||||
|
editor: { getContent: jest.fn(), getSelection: jest.fn(), insertAtCursor: jest.fn(), onContentChanged: jest.fn() },
|
||||||
|
ipc: { invoke: jest.fn(), on: jest.fn() },
|
||||||
|
exportHooks: { preHooks: [], postHooks: [] }
|
||||||
|
};
|
||||||
|
context = new PluginContext(mockDeps);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes sidebar.registerPanel with namespaced id', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.sidebar.registerPanel('my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||||
|
expect(mockDeps.sidebar.registerPanel).toHaveBeenCalledWith('test-plugin:my-panel', { icon: 'test', title: 'Test', render: handler });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes commands.register with crash-safe wrapper', () => {
|
||||||
|
const badHandler = () => { throw new Error('boom'); };
|
||||||
|
context.commands.register('bad-cmd', 'Bad', badHandler, 'Ctrl+Alt+T');
|
||||||
|
const registeredHandler = mockDeps.commands.register.mock.calls[0][2];
|
||||||
|
expect(() => registeredHandler()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes settings.get/set scoped to plugin', () => {
|
||||||
|
context.settings.get('myKey');
|
||||||
|
context.settings.set('myKey', 'myValue');
|
||||||
|
expect(mockDeps.settings.get).toHaveBeenCalledWith('plugins.test-plugin.myKey');
|
||||||
|
expect(mockDeps.settings.set).toHaveBeenCalledWith('plugins.test-plugin.myKey', 'myValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes editor methods', () => {
|
||||||
|
context.editor.getContent();
|
||||||
|
context.editor.getSelection();
|
||||||
|
expect(mockDeps.editor.getContent).toHaveBeenCalled();
|
||||||
|
expect(mockDeps.editor.getSelection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes events via event bus', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.events.on('test:event', handler);
|
||||||
|
context.events.emit('test:event', { data: 1 });
|
||||||
|
expect(handler).toHaveBeenCalledWith({ data: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes events.hasHandler', () => {
|
||||||
|
expect(context.events.hasHandler('no:such')).toBe(false);
|
||||||
|
context.events.on('exists:event', () => {});
|
||||||
|
expect(context.events.hasHandler('exists:event')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes ipc.invoke', () => {
|
||||||
|
context.ipc.invoke('test:channel', { a: 1 });
|
||||||
|
expect(mockDeps.ipc.invoke).toHaveBeenCalledWith('test:channel', { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes exports.registerPreHook', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.exports.registerPreHook(handler);
|
||||||
|
expect(mockDeps.exportHooks.preHooks).toContain(handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exposes exports.registerPostHook', () => {
|
||||||
|
const handler = jest.fn();
|
||||||
|
context.exports.registerPostHook(handler);
|
||||||
|
expect(mockDeps.exportHooks.postHooks).toContain(handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const { PluginLoader } = require('../src/plugins/plugin-loader');
|
||||||
|
|
||||||
|
describe('PluginLoader', () => {
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeManifest(dir, manifest) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('discoverPlugins — finds manifests in built-in directory', () => {
|
||||||
|
const pluginDir = path.join(tempDir, 'my-plugin');
|
||||||
|
writeManifest(pluginDir, {
|
||||||
|
id: 'my-plugin',
|
||||||
|
name: 'My Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Test'
|
||||||
|
});
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].id).toBe('my-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — skips directories without manifest.json', () => {
|
||||||
|
const emptyDir = path.join(tempDir, 'no-manifest');
|
||||||
|
fs.mkdirSync(emptyDir, { recursive: true });
|
||||||
|
const loader = new PluginLoader([emptyDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — accepts valid manifest', () => {
|
||||||
|
const manifest = {
|
||||||
|
id: 'test-plugin',
|
||||||
|
name: 'Test Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A test plugin'
|
||||||
|
};
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(loader.validateManifest(manifest)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest missing id', () => {
|
||||||
|
const manifest = { name: 'No ID', version: '1.0.0', description: 'x' };
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(() => loader.validateManifest(manifest)).toThrow(/id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest missing name', () => {
|
||||||
|
const manifest = { id: 'test', version: '1.0.0', description: 'x' };
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
expect(() => loader.validateManifest(manifest)).toThrow(/name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateManifest — rejects manifest with duplicate id', () => {
|
||||||
|
const loader = new PluginLoader([]);
|
||||||
|
loader.loadedIds = new Set(['existing-plugin']);
|
||||||
|
expect(() => loader.validateManifest({ id: 'existing-plugin', name: 'Dup', version: '1.0.0', description: 'x' }))
|
||||||
|
.toThrow(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — loads index.js Plugin export if present', () => {
|
||||||
|
const pluginDir = path.join(tempDir, 'with-index');
|
||||||
|
writeManifest(pluginDir, { id: 'with-index', name: 'With Index', version: '1.0.0', description: 'Test' });
|
||||||
|
// Write a simple index.js with a Plugin export
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'index.js'), `
|
||||||
|
class SimplePlugin { init() {} }
|
||||||
|
module.exports = { Plugin: SimplePlugin };
|
||||||
|
`);
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].PluginClass).toBeDefined();
|
||||||
|
expect(plugins[0].PluginClass.name).toBe('SimplePlugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discoverPlugins — continues if one plugin fails to load', () => {
|
||||||
|
const badDir = path.join(tempDir, 'bad');
|
||||||
|
writeManifest(badDir, { id: 'bad', name: 'Bad', version: '1.0.0', description: 'Broken' });
|
||||||
|
fs.writeFileSync(path.join(badDir, 'index.js'), 'throw new Error("broken");');
|
||||||
|
|
||||||
|
const goodDir = path.join(tempDir, 'good');
|
||||||
|
writeManifest(goodDir, { id: 'good', name: 'Good', version: '1.0.0', description: 'Works' });
|
||||||
|
|
||||||
|
const loader = new PluginLoader([tempDir]);
|
||||||
|
const plugins = loader.discoverPlugins();
|
||||||
|
expect(plugins).toHaveLength(1);
|
||||||
|
expect(plugins[0].id).toBe('good');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
|
||||||
|
|
||||||
|
describe('ProjectManager', () => {
|
||||||
|
let pm;
|
||||||
|
let files;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
files = {};
|
||||||
|
pm = new ProjectManager({
|
||||||
|
readFile: (p) => files[p] || null,
|
||||||
|
writeFile: (p, c) => { files[p] = c; },
|
||||||
|
fileExists: (p) => p in files,
|
||||||
|
listDir: (p) => Object.keys(files).filter(f => f.startsWith(p)).map(f => f.slice(p.length + 1))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createProject writes .project.json', () => {
|
||||||
|
const project = pm.createProject('/manuscripts/novel', {
|
||||||
|
title: 'My Novel', type: 'manuscript', targetWords: 80000
|
||||||
|
});
|
||||||
|
expect(project.title).toBe('My Novel');
|
||||||
|
expect(files['/manuscripts/novel/.project.json']).toBeDefined();
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.target.words).toBe(80000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProject reads and returns project data', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [], metadata: {}
|
||||||
|
});
|
||||||
|
const project = pm.loadProject('/manuscripts/novel');
|
||||||
|
expect(project.title).toBe('Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProject returns null if no project file', () => {
|
||||||
|
expect(pm.loadProject('/nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addChapter appends chapter and saves', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [], metadata: {}
|
||||||
|
});
|
||||||
|
pm.addChapter('/manuscripts/novel', { file: '01-chapter.md', title: 'Chapter One', status: 'draft' });
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.chapters.length).toBe(1);
|
||||||
|
expect(parsed.chapters[0].title).toBe('Chapter One');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileManuscript concatenates chapter files', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||||
|
], metadata: {}
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'First chapter content.';
|
||||||
|
files['/manuscripts/novel/02.md'] = 'Second chapter content.';
|
||||||
|
const result = pm.compileManuscript('/manuscripts/novel');
|
||||||
|
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compileManuscript skips missing files', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||||
|
], metadata: {}
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'Only chapter one.';
|
||||||
|
const result = pm.compileManuscript('/manuscripts/novel');
|
||||||
|
expect(result).toBe('Only chapter one.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStats returns total word count across chapters', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [
|
||||||
|
{ file: '01.md', title: 'One', status: 'draft' },
|
||||||
|
{ file: '02.md', title: 'Two', status: 'draft' }
|
||||||
|
], metadata: {}
|
||||||
|
});
|
||||||
|
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
|
||||||
|
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
|
||||||
|
const stats = pm.getStats('/manuscripts/novel');
|
||||||
|
expect(stats.totalWords).toBeGreaterThan(0);
|
||||||
|
expect(stats.chapterCount).toBe(2);
|
||||||
|
expect(stats.targetWords).toBe(50000);
|
||||||
|
expect(stats.pctComplete).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateChapter modifies a chapter by index', () => {
|
||||||
|
files['/manuscripts/novel/.project.json'] = JSON.stringify({
|
||||||
|
title: 'Test', type: 'manuscript', target: { words: 50000 },
|
||||||
|
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
|
||||||
|
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
|
||||||
|
expect(parsed.chapters[0].title).toBe('New Title');
|
||||||
|
expect(parsed.chapters[0].status).toBe('revised');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Security: Path Traversal Prevention Test Suite
|
||||||
|
* @version 4.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
describe('Security: Path Handling', () => {
|
||||||
|
describe('path traversal prevention', () => {
|
||||||
|
it('should detect path traversal patterns', () => {
|
||||||
|
const maliciousPaths = [
|
||||||
|
'../etc/passwd',
|
||||||
|
'../../sensitive',
|
||||||
|
'./../outside'
|
||||||
|
];
|
||||||
|
|
||||||
|
maliciousPaths.forEach(pathStr => {
|
||||||
|
// Path traversal attempts contain .. patterns
|
||||||
|
expect(pathStr).toMatch(/\.\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize relative paths safely', () => {
|
||||||
|
const safePaths = [
|
||||||
|
'./documents/file.md',
|
||||||
|
'relative/path/file.txt'
|
||||||
|
];
|
||||||
|
|
||||||
|
safePaths.forEach(pathStr => {
|
||||||
|
const normalized = path.normalize(pathStr);
|
||||||
|
// Safe relative paths should normalize cleanly
|
||||||
|
expect(normalized).toBeDefined();
|
||||||
|
expect(typeof normalized).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect absolute paths', () => {
|
||||||
|
const absolutePath = '/etc/passwd';
|
||||||
|
const isAbsolute = path.isAbsolute(absolutePath);
|
||||||
|
|
||||||
|
// Linux/Mac: /path is absolute
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
expect(isAbsolute).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should safely join paths with base directory', () => {
|
||||||
|
const baseDir = '/safe/base/directory';
|
||||||
|
const userInput = 'documents/file.md';
|
||||||
|
|
||||||
|
const joined = path.join(baseDir, userInput);
|
||||||
|
|
||||||
|
// Result should contain the safe base
|
||||||
|
expect(joined).toContain('base');
|
||||||
|
expect(joined).toContain('documents');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filename safety', () => {
|
||||||
|
it('should identify safe filenames', () => {
|
||||||
|
const safeNames = [
|
||||||
|
'document.md',
|
||||||
|
'my-file.txt',
|
||||||
|
'file_name.pdf',
|
||||||
|
'report_2026_04_24.xlsx'
|
||||||
|
];
|
||||||
|
|
||||||
|
safeNames.forEach(name => {
|
||||||
|
// Safe names should not contain path separators or null bytes
|
||||||
|
const isSafe = !/[\\/\0]/.test(name) && name.length > 0;
|
||||||
|
expect(isSafe).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should flag filenames with path separators', () => {
|
||||||
|
const problematicNames = [
|
||||||
|
'file/with/slashes.txt',
|
||||||
|
'file\\with\\backslashes.txt'
|
||||||
|
];
|
||||||
|
|
||||||
|
problematicNames.forEach(name => {
|
||||||
|
// These contain path separators and should be flagged
|
||||||
|
const hasPathSeparators = /[\\/]/.test(name);
|
||||||
|
expect(hasPathSeparators).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enforce minimum filename length', () => {
|
||||||
|
const emptyName = '';
|
||||||
|
|
||||||
|
expect(emptyName.length).toBe(0);
|
||||||
|
expect(emptyName.length > 0).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation patterns', () => {
|
||||||
|
it('should validate path existence check pattern', () => {
|
||||||
|
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
|
||||||
|
|
||||||
|
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||||
|
validPaths.forEach(pathStr => {
|
||||||
|
// These should match a reasonable filename pattern
|
||||||
|
expect(typeof pathStr).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent null byte injection', () => {
|
||||||
|
const pathWithNullByte = 'file.txt\0.exe';
|
||||||
|
|
||||||
|
const isSafe = !pathWithNullByte.includes('\0');
|
||||||
|
expect(isSafe).toBe(false); // Has null byte, not safe
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const { SettingsStore } = require('../src/plugins/settings-store');
|
||||||
|
|
||||||
|
describe('SettingsStore', () => {
|
||||||
|
let store;
|
||||||
|
let data;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
data = {};
|
||||||
|
store = new SettingsStore({
|
||||||
|
get: (key) => data[key],
|
||||||
|
set: (key, value) => { data[key] = value; }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns value for full key', () => {
|
||||||
|
data['plugins.my-plugin.myKey'] = 'myValue';
|
||||||
|
expect(store.get('plugins.my-plugin.myKey')).toBe('myValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns undefined for missing key', () => {
|
||||||
|
expect(store.get('plugins.my-plugin.missing')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set stores value', () => {
|
||||||
|
store.set('plugins.my-plugin.myKey', 42);
|
||||||
|
expect(data['plugins.my-plugin.myKey']).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set overwrites existing value', () => {
|
||||||
|
data['plugins.my-plugin.myKey'] = 'old';
|
||||||
|
store.set('plugins.my-plugin.myKey', 'new');
|
||||||
|
expect(data['plugins.my-plugin.myKey']).toBe('new');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user