Compare commits

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

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

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

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

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

Amit Haridas
2026-04-06 07:36:18 +05:30
30 changed files with 3089 additions and 88 deletions
+3
View File
@@ -23,3 +23,6 @@ jobs:
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Run linter
run: npm run lint
+25 -9
View File
@@ -43,7 +43,7 @@ jobs:
retention-days: 5 retention-days: 5
build-windows: build-windows:
runs-on: ubuntu-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -53,17 +53,32 @@ jobs:
node-version: 20 node-version: 20
cache: npm cache: npm
- name: Install Wine and NSIS
run: |
sudo dpkg --add-architecture i386
sudo apt-get update
sudo apt-get install -y wine64 wine32 nsis
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Build Windows packages - name: Run tests
run: npm run build:win run: npm test
- name: Decode certificate (if available)
if: ${{ secrets.CSC_LINK_BASE64 != '' }}
shell: pwsh
run: |
$bytes = [Convert]::FromBase64String("${{ secrets.CSC_LINK_BASE64 }}")
[IO.File]::WriteAllBytes("${{ github.workspace }}\code-signing-cert.pfx", $bytes)
echo "CERT_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Build Windows packages (signed)
if: ${{ env.CERT_AVAILABLE == 'true' }}
env:
CSC_LINK: code-signing-cert.pfx
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run build:win-signed
- name: Build Windows packages (unsigned)
if: ${{ env.CERT_AVAILABLE != 'true' }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
run: npm run build:win-unsigned
- name: Upload Windows artifacts - name: Upload Windows artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -72,6 +87,7 @@ jobs:
path: | path: |
dist/*.exe dist/*.exe
dist/*-win.zip dist/*-win.zip
dist/*.zip
retention-days: 5 retention-days: 5
release: release:
+8 -2
View File
@@ -8,7 +8,8 @@ Thumbs.db
*.swp *.swp
*.swo *.swo
*~ *~
.vscode/ .vscode/*
!.vscode/launch.json
.idea/ .idea/
*.iml *.iml
out/ out/
@@ -17,6 +18,10 @@ out/
.electron/ .electron/
package-lock.json package-lock.json
# Code signing certificates — never commit private keys
*.pfx
*.p12
# Screenshots and temp files # Screenshots and temp files
*.png.bak *.png.bak
Screen.png Screen.png
@@ -33,4 +38,5 @@ pdf\ modal.png
# Claude/AI development files # Claude/AI development files
.claude/ .claude/
CLAUDE.md CLAUDE.md
agents.mdcoverage/ agents.md
coverage/
+48
View File
@@ -0,0 +1,48 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": ["."],
"outputCapture": "std",
"env": {
"NODE_ENV": "development"
}
},
{
"name": "Debug Renderer Process",
"type": "chrome",
"request": "attach",
"port": 9222,
"webRoot": "${workspaceFolder}/src",
"timeout": 30000
},
{
"name": "Debug Main + Renderer",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": [".", "--remote-debugging-port=9222"],
"outputCapture": "std",
"env": {
"NODE_ENV": "development"
},
"serverReadyAction": {
"pattern": "listening on port ([0-9]+)",
"uriFormat": "http://localhost:%s",
"action": "debugWithChrome"
}
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Repository Guidelines
## Project Structure & Module Organization
Core application code lives in `src/`. Use `src/main.js` for the Electron main process, `src/preload.js` for the preload bridge, and `src/renderer.js` plus `src/editor/`, `src/sidebar/`, `src/repl/`, and `src/utils/` for renderer-side features. Electron adapter code is in `src/adapters/electron/`. Reusable markdown/document templates live in `src/templates/`. Static assets and icons are in `assets/`. Tests are in `tests/`, and build output goes to `dist/`.
## Build, Test, and Development Commands
- `npm start`: launch the Electron app locally.
- `npm test`: run the Jest suite once.
- `npm run test:watch`: rerun tests during local development.
- `npm run test:coverage`: generate coverage output.
- `npm run lint` / `npm run lint:fix`: check or fix ESLint issues in `src` and `tests`.
- `npm run format` / `npm run format:check`: apply or verify Prettier formatting.
- `npm run build:linux`, `npm run build:win`, `npm run build:mac`: create platform packages with `electron-builder`.
## Coding Style & Naming Conventions
This repo uses Prettier and ESLint. Follow `.prettierrc`: 2-space indentation, single quotes, semicolons, trailing commas where valid in ES5, and a 100-character line width. Prefer `camelCase` for variables/functions, `PascalCase` for classes, and kebab-case for file names only when already established. Keep module boundaries clear: UI logic in renderer modules, OS/file-system work behind Electron IPC and adapters.
## Testing Guidelines
Tests use Jest with `jest-environment-jsdom`. Add new tests under `tests/` with `*.test.js` names, mirroring the feature area when possible, for example `tests/sidebar.test.js` or `tests/print-preview.test.js`. Update or add regression tests for renderer behavior, preload APIs, and utility helpers when fixing bugs. Run `npm test` before opening a PR; use `npm run test:coverage` for larger refactors.
## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `refactor:`. Keep subjects short and imperative, for example `fix: guard modal cleanup on close`. PRs should describe the user-visible change, note test coverage, link any related issue, and include screenshots or GIFs for UI changes.
## Security & Configuration Tips
Do not bypass preload boundaries or introduce direct `eval`/dynamic code paths; ESLint already treats these as errors. Export and conversion features depend on external tools such as Pandoc, FFmpeg, ImageMagick, and LibreOffice, so document any new runtime dependency in `README.md` and packaging config.
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v3.0.0 v4.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
# MarkdownConverter - STRIDE Threat Model Analysis # MarkdownConverter - STRIDE Threat Model Analysis
**Version:** 4.0.0 **Version:** 4.1.0
**Date:** 2026-03-15 **Date:** 2026-03-15
**Methodology:** STRIDE + MITRE ATT&CK Mapping **Methodology:** STRIDE + MITRE ATT&CK Mapping
**Analyst:** Security Assessment Team **Analyst:** Security Assessment Team
@@ -0,0 +1,335 @@
# Writer's Studio Feature Pack — Design Document
**Date**: 2026-04-06
**Version**: 4.2.0 target
**Status**: Approved
**Scope**: Three cohesive features to transform MarkdownConverter into a writing environment
---
## Overview
The Writer's Studio Feature Pack adds three interconnected features to MarkdownConverter:
1. **Zen Mode** — Distraction-free writing environment with typewriter scrolling
2. **Document Outline** — Heading hierarchy sidebar panel for navigation
3. **Writing Analytics** — Real-time readability and vocabulary analysis dashboard
These features work together: Zen Mode creates the environment, Outline provides navigation, Analytics gives insight.
---
## Feature 1: Zen Mode
### Purpose
Transform the app from a multi-tool into a focused writing environment. Inspired by iA Writer, Typora, and Bear.
### New Files
- `src/zen-mode.js` — ZenMode class (~150 lines)
- `src/styles-zen.css` — Zen mode specific styles (~120 lines)
### Integration Points
- `src/renderer.js` — Initialize ZenMode, register F11 shortcut, add View > Zen Mode menu
- `src/editor/codemirror-setup.js` — Export typewriter + dimming extensions
### Behavior
**Toggle**: F11, View > Zen Mode, command palette "Toggle Zen Mode"
**Exit**: Escape key, F11 again
**What hides**:
- Tab bar
- Toolbar
- Sidebar (collapsed)
- Status bar
- App header
**What shows**:
- Editor (full viewport)
- Floating HUD (bottom-center, semi-transparent)
### Floating HUD
```
┌─────────────────────────────────────────┐
│ 847 words • ~4 min • 23:45 session │
│ ████████████████░░░░ 85% of 1000 │
└─────────────────────────────────────────┘
```
- Word count (from existing status bar logic)
- Estimated reading time (~200 wpm)
- Session timer (starts when zen mode activates)
- Optional progress bar toward word goal
### CodeMirror Extensions
**Typewriter Scroll** (`ViewPlugin`):
- Listens to `EditorView.update` for selection changes
- Calls `editor.dispatch({ effects: EditorView.scrollIntoView(pos, { y: 'center' }) })`
- Smooth scrolling with `scrollBehavior: 'smooth'` in CSS
**Line Dimming** (`ViewPlugin` + `Decoration`):
- Builds a `DecorationSet` mapping each line to an opacity value
- Active line: opacity 1.0
- 1-2 lines away: 0.7
- 3-4 lines away: 0.5
- 5+ lines away: 0.3
- Uses `Decoration.line({ attributes: { style: 'opacity: X' } })`
### Centered Column
CSS applied to `.zen-mode .cm-content`:
```css
.zen-mode .cm-content {
max-width: 700px;
margin: 0 auto;
font-size: 18px;
line-height: 1.8;
}
```
### State Management
- `this.previousState` stores which UI elements were visible before zen mode
- On exit, restores all elements to their previous visibility
- Editor content, cursor position, and scroll state are never modified
---
## Feature 2: Document Outline Panel
### Purpose
Provide always-visible heading navigation for documents of any length. The single most-requested navigation feature for multi-section documents.
### New Files
- `src/sidebar/outline-panel.js``renderOutlinePanel` function (~100 lines)
### Modified Files
- `src/index.html` — Add outline icon button in sidebar icons strip
- `src/renderer.js` — Register 'outline' panel, provide editor reference
### Sidebar Integration
Uses existing `SidebarManager.registerPanel()` API:
```javascript
sidebarManager.registerPanel('outline', {
title: 'Outline',
render: (container) => renderOutlinePanel(container, editor, editorContent)
});
```
New icon button in sidebar strip (after templates icon):
```html
<button class="sidebar-icon" data-panel="outline" title="Outline (Ctrl+Shift+O)">
<!-- hierarchy/list icon SVG -->
</button>
```
### Parsing Logic
Parse headings from raw markdown content using regex:
```javascript
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
```
Returns array of:
```javascript
{ level: 1-6, text: "Heading Text", line: 42 }
```
Debounced at 300ms to avoid re-parsing on every keystroke.
### UI Structure
```
┌──────────────────────────────────────────┐
│ OUTLINE ☰ │
├──────────────────────────────────────────┤
│ ▸ Introduction (H1) │
│ ▸ Getting Started (H2) │
│ ▸ Prerequisites (H2) │
│ ▸ Node.js (H3) ◄ │
│ ▸ Installation (H2) │
│ ▸ Features (H1) │
│ ▸ Editor (H2) │
│ ▸ Export (H2) │
├──────────────────────────────────────────┤
│ 9 headings • 2 H1 • 4 H2 • 3 H3 │
└──────────────────────────────────────────┘
```
- Indentation based on heading level (H1 = 0px, H2 = 16px, H3 = 32px, etc.)
- Current heading highlighted with accent color (◄ indicator)
- Hover shows full heading text if truncated
### Click-to-Navigate
```javascript
editor.dispatch({
effects: EditorView.scrollIntoView(linePos, { y: 'center' })
});
```
Brief highlight animation on the target line (fades out over 500ms).
### Current Heading Sync
On editor update (debounced 100ms):
1. Get cursor line number
2. Find the last heading whose line number <= cursor line
3. Set that heading as active in the outline
### Empty State
When no headings found:
```
No headings found
Use # to create headings:
# Heading 1
## Heading 2
### Heading 3
```
---
## Feature 3: Writing Analytics
### Purpose
Give writers real-time insight into their document's readability, structure, and vocabulary. This is the "surprise" feature most Markdown editors lack.
### New Files
- `src/analytics/writing-analytics.js``WritingAnalytics` class (~180 lines)
- `src/analytics/analytics-panel.js``renderAnalyticsPanel` function (~120 lines)
### Integration Points
- `src/renderer.js` — Register Ctrl+Shift+A shortcut, command palette entry, View menu item
### Trigger
- Keyboard: `Ctrl+Shift+A`
- Command Palette: "Show Writing Analytics"
- Menu: View > Writing Analytics
### Presentation
Uses existing `ModalManager` to show a modal overlay with analytics dashboard.
```
┌─────────────────────────────────────────────────────┐
│ Writing Analytics ✕ │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─ Readability ──────────────────────────────────┐ │
│ │ Flesch Reading Ease: 67.3 (Standard) ○ │ │
│ │ Grade Level: 8.2 ○○○●○ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ Timing ───────────────────────────────────────┐ │
│ │ Reading Time: ~4 min │ │
│ │ Speaking Time: ~6 min │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ Structure ────────────────────────────────────┐ │
│ │ Sentences: 42 • Paragraphs: 8 │ │
│ │ Avg Sentence: 14.2 words │ │
│ │ Longest: 38 words ("The quick brown fox...") │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ Vocabulary ───────────────────────────────────┐ │
│ │ Unique: 312 / 847 words (36.8%) │ │
│ │ Top: the(42) and(31) markdown(28) ... │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─ Word Goal ────────────────────────────────────┐ │
│ │ Target: [1000] words │ │
│ │ ████████████████░░░░ 847/1000 (85%) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
### Metrics Implementation
**Readability (Flesch-Kincaid):**
```javascript
// Flesch Reading Ease
ease = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
// Flesch-Kincaid Grade Level
grade = 0.39 * (words / sentences) + 11.8 * (syllables / words) - 15.59;
```
**Syllable Estimation:**
```javascript
function countSyllables(word) {
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/g)?.length || 1;
}
```
**Reading/Speaking Time:**
- Reading: 200 words/minute
- Speaking: 130 words/minute
**Lexical Diversity:**
- Ratio of unique words to total words (excluding stop words)
**Top Words:**
- Frequency map, sorted descending, top 10
- Excludes common stop words (the, a, an, is, are, etc.)
### Word Goal
- Persisted in `electron-store` per document (or global default)
- Progress bar with percentage
- Celebration effect when goal is reached (brief confetti animation or green flash)
### Update Cadence
- Re-analyzes on editor content change (debounced at 1000ms)
- If modal is open, updates live
- If modal is closed, no computation (zero overhead)
---
## File Summary
| File | Action | Purpose |
|------|--------|---------|
| `src/zen-mode.js` | Create | ZenMode class with CM6 extensions |
| `src/styles-zen.css` | Create | Zen mode styling |
| `src/sidebar/outline-panel.js` | Create | Outline sidebar panel |
| `src/analytics/writing-analytics.js` | Create | Analytics computation engine |
| `src/analytics/analytics-panel.js` | Create | Analytics modal UI |
| `src/index.html` | Modify | Add outline icon, zen mode button |
| `src/renderer.js` | Modify | Initialize all three features |
| `src/editor/codemirror-setup.js` | Modify | Export typewriter + dimming extensions |
## Keyboard Shortcuts
| Shortcut | Feature | Action |
|----------|---------|--------|
| F11 | Zen Mode | Toggle on/off |
| Escape | Zen Mode | Exit (when active) |
| Ctrl+Shift+O | Outline | Open outline sidebar panel |
| Ctrl+Shift+A | Analytics | Open analytics modal |
## Dependencies
No new npm dependencies required. All features use:
- Existing CodeMirror 6 APIs (ViewPlugin, Decoration, scrollIntoView)
- Existing SidebarManager API
- Existing ModalManager API
- Pure JavaScript math for analytics
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.1.0", "version": "4.3.0",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
@@ -14,8 +14,9 @@
"format:check": "prettier --check src tests", "format:check": "prettier --check src tests",
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win", "build:win": "electron-builder --win",
"build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx CSC_KEY_PASSWORD=%CSC_KEY_PASSWORD% electron-builder --win", "build:win-signed": "cross-env CSC_LINK=code-signing-cert.pfx electron-builder --win",
"build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win", "build:win-unsigned": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --win",
"create-cert": "powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1",
"build:mac": "electron-builder --mac", "build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux", "build:linux": "electron-builder --linux",
"build:local": "electron-builder --linux --win", "build:local": "electron-builder --linux --win",
@@ -42,7 +43,7 @@
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^37.4.0", "electron": "^41.1.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@@ -89,11 +90,14 @@
"overrides": { "overrides": {
"jszip": "^3.10.1", "jszip": "^3.10.1",
"nth-check": "^2.1.1", "nth-check": "^2.1.1",
"lodash.pick": "npm:lodash@^4.17.21" "lodash.pick": "npm:lodash@^4.17.21",
"lodash-es": "^4.18.1",
"lodash": "^4.17.21"
}, },
"build": { "build": {
"appId": "com.concreteinfo.markdownconverter", "appId": "com.concreteinfo.markdownconverter",
"productName": "MarkdownConverter", "productName": "MarkdownConverter",
"copyright": "Copyright (C) 2024-2025 ConcreteInfo",
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
@@ -155,7 +159,8 @@
], ],
"artifactName": "${productName}-${version}-${arch}.${ext}", "artifactName": "${productName}-${version}-${arch}.${ext}",
"requestedExecutionLevel": "asInvoker", "requestedExecutionLevel": "asInvoker",
"signAndEditExecutable": false "legalTrademarks": "Copyright (C) 2024-2025 ConcreteInfo",
"verifyUpdateCodeSignature": false
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
+59
View File
@@ -0,0 +1,59 @@
# create-selfsigned-cert.ps1
# Generates a self-signed code-signing certificate for local/development builds.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts/create-selfsigned-cert.ps1
# npm run create-cert
#
# Then build with:
# $env:CSC_LINK="code-signing-cert.pfx"; $env:CSC_KEY_PASSWORD="YourPassword"; npm run build:win-signed
#
# NOTE: Self-signed certificates will still show a SmartScreen warning for end users.
# For production releases, obtain an OV or EV certificate from a trusted CA
# (DigiCert, Sectigo, Certum, etc.). EV certificates bypass SmartScreen immediately.
# Open-source projects can apply for free signing at https://signpath.io/
param(
[string]$CertPassword = "MarkdownConverter2025",
[string]$OutputFile = "code-signing-cert.pfx",
[string]$Subject = "CN=ConcreteInfo, O=ConcreteInfo, L=India, C=IN"
)
Write-Host "Creating self-signed code-signing certificate..." -ForegroundColor Cyan
# Create the certificate in the current user's certificate store
$cert = New-SelfSignedCertificate `
-Type CodeSigningCert `
-Subject $Subject `
-CertStoreLocation "Cert:\CurrentUser\My" `
-NotAfter (Get-Date).AddYears(3) `
-HashAlgorithm SHA256 `
-KeyLength 4096 `
-KeyUsage DigitalSignature
if (-not $cert) {
Write-Error "Failed to create certificate."
exit 1
}
Write-Host "Certificate created: $($cert.Thumbprint)" -ForegroundColor Green
# Export to PFX
$securePassword = ConvertTo-SecureString -String $CertPassword -Force -AsPlainText
$exportPath = Join-Path (Get-Location) $OutputFile
Export-PfxCertificate -Cert $cert -FilePath $exportPath -Password $securePassword | Out-Null
if (Test-Path $exportPath) {
Write-Host "Certificate exported to: $exportPath" -ForegroundColor Green
Write-Host ""
Write-Host "To build a signed release:" -ForegroundColor Yellow
Write-Host ' $env:CSC_LINK="code-signing-cert.pfx"' -ForegroundColor White
Write-Host " `$env:CSC_KEY_PASSWORD=`"$CertPassword`"" -ForegroundColor White
Write-Host " npm run build:win-signed" -ForegroundColor White
Write-Host ""
Write-Host "IMPORTANT: Add code-signing-cert.pfx to .gitignore!" -ForegroundColor Red
} else {
Write-Error "Export failed."
exit 1
}
+20 -16
View File
@@ -18,7 +18,7 @@ const electronFsAdapter = {
* @returns {Promise<string>} File content * @returns {Promise<string>} File content
*/ */
async readFile(path) { async readFile(path) {
return await window.electronAPI.readFile(path); return await window.electronAPI.file.read(path);
}, },
/** /**
@@ -28,7 +28,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async writeFile(path, content) { async writeFile(path, content) {
return await window.electronAPI.writeFile(path, content); return await window.electronAPI.file.write(path, content);
}, },
/** /**
@@ -37,8 +37,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async deleteFile(path) { async deleteFile(path) {
// TODO: Add IPC channel for delete return await window.electronAPI.file.delete(path);
throw new Error('deleteFile not implemented');
}, },
/** /**
@@ -47,8 +46,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async ensureDir(path) { async ensureDir(path) {
// TODO: Add IPC channel for ensureDir return await window.electronAPI.file.ensureDir(path);
throw new Error('ensureDir not implemented');
}, },
/** /**
@@ -57,8 +55,18 @@ const electronFsAdapter = {
* @returns {Promise<Array<import('../types').FileInfo>>} * @returns {Promise<Array<import('../types').FileInfo>>}
*/ */
async listDirectory(path) { async listDirectory(path) {
// TODO: Add IPC channel for listDirectory const result = await window.electronAPI.invoke('list-directory', path);
throw new Error('listDirectory not implemented'); if (!result?.entries) {
return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path
}));
}, },
/** /**
@@ -67,8 +75,7 @@ const electronFsAdapter = {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async exists(path) { async exists(path) {
// TODO: Add IPC channel for exists return await window.electronAPI.file.exists(path);
throw new Error('exists not implemented');
}, },
/** /**
@@ -77,8 +84,7 @@ const electronFsAdapter = {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async isDirectory(path) { async isDirectory(path) {
// TODO: Add IPC channel for isDirectory return await window.electronAPI.file.isDirectory(path);
throw new Error('isDirectory not implemented');
}, },
/** /**
@@ -88,8 +94,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async copy(source, dest) { async copy(source, dest) {
// TODO: Add IPC channel for copy return await window.electronAPI.file.copy(source, dest);
throw new Error('copy not implemented');
}, },
/** /**
@@ -99,8 +104,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async move(source, dest) { async move(source, dest) {
// TODO: Add IPC channel for move return await window.electronAPI.file.move(source, dest);
throw new Error('move not implemented');
} }
}; };
+112
View File
@@ -0,0 +1,112 @@
/**
* Writing Analytics Panel — modal overlay displaying analytics dashboard
*/
const { analyze } = require('./writing-analytics');
function showAnalyticsModal(tabManager) {
const existing = document.getElementById('analytics-modal');
if (existing) existing.remove();
const content = tabManager.getEditorContent();
const metrics = analyze(content);
const overlay = document.createElement('div');
overlay.id = 'analytics-modal';
overlay.className = 'analytics-overlay';
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
overlay.innerHTML = `
<div class="analytics-modal">
<div class="analytics-header">
<h2>Writing Analytics</h2>
<button class="analytics-close" title="Close">&times;</button>
</div>
<div class="analytics-body">
<div class="analytics-section">
<h3>Readability</h3>
<div class="analytics-row">
<span class="analytics-label">Flesch Reading Ease</span>
<span class="analytics-value">${metrics.fleschEase}<small>${metrics.readabilityLabel}</small></span>
</div>
<div class="analytics-row">
<span class="analytics-label">Grade Level</span>
<span class="analytics-value">${metrics.fleschGrade}</span>
</div>
<div class="readability-meter">
<div class="readability-fill" style="width: ${Math.max(0, Math.min(100, metrics.fleschEase))}%"></div>
</div>
</div>
<div class="analytics-section">
<h3>Timing</h3>
<div class="analytics-row">
<span class="analytics-label">Reading Time</span>
<span class="analytics-value">~${metrics.readingTime} min</span>
</div>
<div class="analytics-row">
<span class="analytics-label">Speaking Time</span>
<span class="analytics-value">~${metrics.speakingTime} min</span>
</div>
</div>
<div class="analytics-section">
<h3>Structure</h3>
<div class="analytics-row">
<span class="analytics-label">Sentences</span>
<span class="analytics-value">${metrics.sentenceCount} &bull; Paragraphs: ${metrics.paragraphCount}</span>
</div>
<div class="analytics-row">
<span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div>
${metrics.longestSentenceLength > 0 ? `
<div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''}
</div>
<div class="analytics-section">
<h3>Vocabulary</h3>
<div class="analytics-row">
<span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div>
${metrics.topWords.length > 0 ? `
<div class="word-cloud">
${metrics.topWords.map(w => {
const scale = 13 + Math.round((w.count / maxCount) * 3);
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
}).join('')}
</div>` : ''}
</div>
</div>
</div>
`;
const closeBtn = overlay.querySelector('.analytics-close');
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
module.exports = { showAnalyticsModal };
+127
View File
@@ -0,0 +1,127 @@
/**
* Writing Analytics — pure computation engine
* No DOM dependencies. Exported analyze(text) returns a metrics object.
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with',
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it',
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my',
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her',
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too',
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who'
]);
function countSyllables(word) {
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
}
function extractWords(text) {
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
}
function getReadabilityLabel(score) {
if (score >= 90) return 'Very Easy';
if (score >= 70) return 'Easy';
if (score >= 50) return 'Standard';
if (score >= 30) return 'Difficult';
return 'Very Difficult';
}
function analyze(text) {
if (!text || !text.trim()) {
return {
wordCount: 0,
sentenceCount: 0,
paragraphCount: 0,
fleschEase: 0,
fleschGrade: 0,
readabilityLabel: 'N/A',
readingTime: 0,
speakingTime: 0,
uniqueWordCount: 0,
lexicalDiversity: 0,
avgSentenceLength: 0,
longestSentence: '',
longestSentenceLength: 0,
topWords: []
};
}
const words = extractWords(text);
const wordCount = words.length;
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(Boolean);
const sentenceCount = Math.max(sentences.length, 1);
const paragraphs = text.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean);
const paragraphCount = Math.max(paragraphs.length, 1);
let totalSyllables = 0;
for (const w of words) {
totalSyllables += countSyllables(w);
}
const fleschEase = Math.round((206.835 - 1.015 * (wordCount / sentenceCount) - 84.6 * (totalSyllables / wordCount)) * 10) / 10;
const fleschGrade = Math.round((0.39 * (wordCount / sentenceCount) + 11.8 * (totalSyllables / wordCount) - 15.59) * 10) / 10;
const readabilityLabel = getReadabilityLabel(fleschEase);
const readingTime = Math.ceil(wordCount / 200);
const speakingTime = Math.ceil(wordCount / 130);
const uniqueWords = new Set(words.map(w => w.toLowerCase()));
const uniqueWordCount = uniqueWords.size;
const lexicalDiversity = wordCount > 0 ? Math.round((uniqueWordCount / wordCount) * 1000) / 10 : 0;
const avgSentenceLength = Math.round((wordCount / sentenceCount) * 10) / 10;
let longestSentence = '';
let longestSentenceLength = 0;
for (const s of sentences) {
const sWords = extractWords(s);
if (sWords.length > longestSentenceLength) {
longestSentenceLength = sWords.length;
longestSentence = s.trim();
}
}
if (longestSentence.length > 80) {
longestSentence = longestSentence.substring(0, 80) + '...';
}
const wordFreq = {};
for (const w of words) {
const lower = w.toLowerCase();
if (!STOP_WORDS.has(lower) && lower.length > 1) {
wordFreq[lower] = (wordFreq[lower] || 0) + 1;
}
}
const topWords = Object.entries(wordFreq)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word, count]) => ({ word, count }));
return {
wordCount,
sentenceCount,
paragraphCount,
fleschEase,
fleschGrade,
readabilityLabel,
readingTime,
speakingTime,
uniqueWordCount,
lexicalDiversity,
avgSentenceLength,
longestSentence,
longestSentenceLength,
topWords
};
}
module.exports = { analyze };
+15 -1
View File
@@ -15,7 +15,9 @@
<link rel="stylesheet" href="styles-modern.css"> <link rel="stylesheet" href="styles-modern.css">
<link rel="stylesheet" href="styles-concreteinfo.css"> <link rel="stylesheet" href="styles-concreteinfo.css">
<link rel="stylesheet" href="styles-sidebar.css"> <link rel="stylesheet" href="styles-sidebar.css">
<link rel="stylesheet" href="styles-zen.css">
<link rel="stylesheet" href="styles-welcome.css"> <link rel="stylesheet" href="styles-welcome.css">
<link rel="stylesheet" href="styles-zen.css">
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css"> <link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css">
</head> </head>
<body> <body>
@@ -29,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.1.0</span> <span class="app-version">v4.2.0</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">
@@ -1273,6 +1275,8 @@
</div> </div>
</div> </div>
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
<!-- Progress indicator --> <!-- Progress indicator -->
<div id="pdf-progress" class="batch-progress hidden"> <div id="pdf-progress" class="batch-progress hidden">
<div class="progress-bar"> <div class="progress-bar">
@@ -1437,6 +1441,16 @@
<line x1="9" y1="21" x2="9" y2="9"/> <line x1="9" y1="21" x2="9" y2="9"/>
</svg> </svg>
</button> </button>
<button class="sidebar-icon" data-panel="outline" title="Outline (Ctrl+Shift+O)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
</button>
</div> </div>
<div class="sidebar-panel" id="sidebar-panel"> <div class="sidebar-panel" id="sidebar-panel">
<div class="sidebar-panel-header"> <div class="sidebar-panel-header">
+136 -4
View File
@@ -124,6 +124,35 @@ function validatePath(filePath) {
return { valid: true, resolved }; return { valid: true, resolved };
} }
/**
* Resolves a path for operations where the target may not exist yet.
* Validates string shape and blocks obviously sensitive locations.
* @param {string} filePath
* @returns {{ valid: boolean, resolved: string, error?: string }}
*/
function resolveWritablePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, resolved: '', error: 'Invalid path' };
}
let resolved;
try {
resolved = path.normalize(path.resolve(filePath));
} catch (err) {
return { valid: false, resolved: '', error: 'Invalid path format' };
}
if (resolved.includes('\0')) {
return { valid: false, resolved: '', error: 'Null byte in path' };
}
if (!isPathAccessible(resolved)) {
return { valid: false, resolved, error: 'Path is not accessible' };
}
return { valid: true, resolved };
}
/** /**
* Checks if a resolved path is within allowed directories * Checks if a resolved path is within allowed directories
* For an editor app, we allow access to all user-accessible paths * For an editor app, we allow access to all user-accessible paths
@@ -1000,7 +1029,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.0.0</div> <div class="version">Version 4.1.0</div>
<div class="company"> <div class="company">
<span>by</span> <span>by</span>
@@ -2793,9 +2822,13 @@ ipcMain.on('save-file', (event, { path, content }) => {
currentFile = path; currentFile = path;
}); });
ipcMain.on('save-current-file', (event, content) => { ipcMain.on('save-current-file', (event, payload) => {
if (currentFile) { const content = typeof payload === 'string' ? payload : payload?.content;
fs.writeFileSync(currentFile, content, 'utf-8'); const targetFile = typeof payload === 'string' ? currentFile : payload?.filePath || currentFile;
if (targetFile) {
fs.writeFileSync(targetFile, content, 'utf-8');
currentFile = targetFile;
} else { } else {
saveAsFile(); saveAsFile();
} }
@@ -4344,6 +4377,8 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
.map(e => ({ .map(e => ({
name: e.name, name: e.name,
isDirectory: e.isDirectory(), isDirectory: e.isDirectory(),
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
path: path.join(validation.resolved, e.name) path: path.join(validation.resolved, e.name)
})); }));
return { path: validation.resolved, entries }; return { path: validation.resolved, entries };
@@ -4353,6 +4388,103 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
} }
}); });
ipcMain.handle('read-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
return fs.readFileSync(validation.resolved, 'utf-8');
});
ipcMain.handle('write-file', async (event, payload) => {
const validation = resolveWritablePath(payload?.path);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid file path');
}
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
return { path: validation.resolved };
});
ipcMain.handle('delete-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
fs.rmSync(validation.resolved, { recursive: true, force: false });
return true;
});
ipcMain.handle('ensure-directory', async (event, dirPath) => {
const validation = resolveWritablePath(dirPath);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid directory path');
}
fs.mkdirSync(validation.resolved, { recursive: true });
return validation.resolved;
});
ipcMain.handle('path-exists', async (event, filePath) => {
const validation = resolveWritablePath(filePath);
return validation.valid ? fs.existsSync(validation.resolved) : false;
});
ipcMain.handle('is-directory', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
return false;
}
return fs.statSync(validation.resolved).isDirectory();
});
ipcMain.handle('copy-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
ipcMain.handle('move-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
try {
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
} catch (error) {
if (error.code !== 'EXDEV') {
throw error;
}
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
}
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
// Open a file by path (from explorer panel) // Open a file by path (from explorer panel)
ipcMain.on('open-file-path', (event, filePath) => { ipcMain.on('open-file-path', (event, filePath) => {
try { try {
+18 -2
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted * - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access * - Prevents XSS from escalating to full system access
* *
* @version 4.0.0 * @version 4.1.0
*/ */
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
@@ -101,6 +101,14 @@ const ALLOWED_SEND_CHANNELS = [
// File Explorer // File Explorer
'list-directory', 'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
// Git // Git
'git-status', 'git-status',
@@ -321,7 +329,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath), setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles), saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
clearRecent: () => ipcRenderer.send('clear-recent-files'), clearRecent: () => ipcRenderer.send('clear-recent-files'),
rendererReady: () => ipcRenderer.send('renderer-ready') rendererReady: () => ipcRenderer.send('renderer-ready'),
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
}, },
// Theme Operations // Theme Operations
+207 -38
View File
@@ -1,6 +1,6 @@
/** /**
* MarkdownConverter Renderer Process * MarkdownConverter Renderer Process
* @version 3.0.0 * @version 4.1.0
*/ */
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
@@ -20,10 +20,55 @@ function getRenderTemplatesPanel() { if (!_renderTemplatesPanel) _renderTemplate
function getRenderExplorerPanel() { if (!_renderExplorerPanel) _renderExplorerPanel = require('./sidebar/explorer-panel').renderExplorerPanel; return _renderExplorerPanel; } function getRenderExplorerPanel() { if (!_renderExplorerPanel) _renderExplorerPanel = require('./sidebar/explorer-panel').renderExplorerPanel; return _renderExplorerPanel; }
function getRenderGitPanel() { if (!_renderGitPanel) _renderGitPanel = require('./sidebar/git-panel').renderGitPanel; return _renderGitPanel; } function getRenderGitPanel() { if (!_renderGitPanel) _renderGitPanel = require('./sidebar/git-panel').renderGitPanel; return _renderGitPanel; }
function getRenderSnippetsPanel() { if (!_renderSnippetsPanel) _renderSnippetsPanel = require('./sidebar/snippets-panel').renderSnippetsPanel; return _renderSnippetsPanel; } function getRenderSnippetsPanel() { if (!_renderSnippetsPanel) _renderSnippetsPanel = require('./sidebar/snippets-panel').renderSnippetsPanel; return _renderSnippetsPanel; }
let _renderOutlinePanel;
function getRenderOutlinePanel() { if (!_renderOutlinePanel) _renderOutlinePanel = require('./sidebar/outline-panel').renderOutlinePanel; return _renderOutlinePanel; }
function getReplPanel() { if (!_ReplPanel) _ReplPanel = require('./repl/repl-panel').ReplPanel; return _ReplPanel; } function getReplPanel() { if (!_ReplPanel) _ReplPanel = require('./repl/repl-panel').ReplPanel; return _ReplPanel; }
function getCommandPalette() { if (!_CommandPalette) _CommandPalette = require('./command-palette').CommandPalette; return _CommandPalette; } function getCommandPalette() { if (!_CommandPalette) _CommandPalette = require('./command-palette').CommandPalette; return _CommandPalette; }
function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview; return _PrintPreview; } function getPrintPreview() { if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview; return _PrintPreview; }
function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; } function getCreateWelcomeContent() { if (!_createWelcomeContent) _createWelcomeContent = require('./welcome').createWelcomeContent; return _createWelcomeContent; }
let _ZenMode;
function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode; return _ZenMode; }
let _showAnalyticsModal;
function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; }
function ensureToastContainer() {
let container = document.getElementById('app-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'app-toast-container';
container.className = 'app-toast-container';
document.body.appendChild(container);
}
return container;
}
function notifyUser(message, type = 'info', options = {}) {
if (!message) return;
const { duration = 3500 } = options;
const container = ensureToastContainer();
const toast = document.createElement('div');
toast.className = `app-toast app-toast-${type}`;
toast.setAttribute('role', type === 'warning' ? 'alert' : 'status');
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('visible');
});
const dismiss = () => {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 180);
};
if (duration > 0) {
setTimeout(dismiss, duration);
}
toast.addEventListener('click', dismiss);
return toast;
}
// Configure marked with highlight extension // Configure marked with highlight extension
marked.use(markedHighlight({ marked.use(markedHighlight({
@@ -351,7 +396,7 @@ class TabManager {
} catch (error) { } catch (error) {
console.error('Error loading PDF:', error); console.error('Error loading PDF:', error);
document.getElementById('status-text').textContent = 'Error loading PDF'; document.getElementById('status-text').textContent = 'Error loading PDF';
alert('Error loading PDF: ' + error.message); notifyUser(`Error loading PDF: ${error.message}`, 'warning');
} }
} }
@@ -416,9 +461,11 @@ class TabManager {
this.updatePreview(tab.id); this.updatePreview(tab.id);
this.updateWordCount(); this.updateWordCount();
this.updateTabBar(); this.updateTabBar();
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
}, },
onUpdate: (view) => { onUpdate: (view) => {
this.updateCursorPosition(view); this.updateCursorPosition(view);
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
}, },
isDark, isDark,
showLineNumbers: this.showLineNumbers, showLineNumbers: this.showLineNumbers,
@@ -449,9 +496,10 @@ class TabManager {
// Notify main process about current file for exports // Notify main process about current file for exports
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (tab?.filePath) { ipcRenderer.send('set-current-file', tab?.filePath || null);
ipcRenderer.send('set-current-file', tab.filePath);
} // Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
} }
switchToNextTab() { switchToNextTab() {
@@ -1467,6 +1515,40 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
}); });
let outlinePanelContainer = null;
sidebarManager.registerPanel('outline', {
title: 'Outline',
render: (container) => {
outlinePanelContainer = container;
getRenderOutlinePanel()(container, {
getEditorContent: () => tabManager.getEditorContent(),
getActiveLine: () => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
const pos = tab.editorView.state.selection.main.head;
return tab.editorView.state.doc.lineAt(pos).number;
}
return 1;
},
onHeadingClick: (line) => {
const tab = tabManager.tabs.get(tabManager.activeTabId);
if (tab?.editorView) {
const pos = tab.editorView.state.doc.line(line).from;
tab.editorView.dispatch({
selection: { anchor: pos },
scrollIntoView: true
});
tab.editorView.focus();
}
}
});
}
});
// Initialize Zen Mode
const ZenModeClass = getZenMode();
const zenMode = new ZenModeClass(tabManager);
// Welcome tab on startup // Welcome tab on startup
const hasLaunched = localStorage.getItem('hasLaunchedBefore'); const hasLaunched = localStorage.getItem('hasLaunchedBefore');
const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false'; const showWelcome = localStorage.getItem('showWelcomeOnStartup') !== 'false';
@@ -1601,6 +1683,7 @@ document.addEventListener('DOMContentLoaded', () => {
commandPalette.register('Toggle Sidebar: Git', 'Ctrl+Shift+G', () => sidebarManager.togglePanel('git')); commandPalette.register('Toggle Sidebar: Git', 'Ctrl+Shift+G', () => sidebarManager.togglePanel('git'));
commandPalette.register('Toggle Sidebar: Snippets', '', () => sidebarManager.togglePanel('snippets')); commandPalette.register('Toggle Sidebar: Snippets', '', () => sidebarManager.togglePanel('snippets'));
commandPalette.register('Toggle Sidebar: Templates', '', () => sidebarManager.togglePanel('templates')); commandPalette.register('Toggle Sidebar: Templates', '', () => sidebarManager.togglePanel('templates'));
commandPalette.register('Toggle Sidebar: Outline', 'Ctrl+Shift+O', () => sidebarManager.togglePanel('outline'));
commandPalette.register('Print Preview', 'Ctrl+P', () => { commandPalette.register('Print Preview', 'Ctrl+P', () => {
const tab = tabManager.tabs.get(tabManager.activeTabId); const tab = tabManager.tabs.get(tabManager.activeTabId);
const preview = document.getElementById(`preview-${tab.id}`); const preview = document.getElementById(`preview-${tab.id}`);
@@ -1618,13 +1701,30 @@ document.addEventListener('DOMContentLoaded', () => {
commandPalette.register('Heading 3', '', () => tabManager.insertAtLineStart('### ')); commandPalette.register('Heading 3', '', () => tabManager.insertAtLineStart('### '));
commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)')); commandPalette.register('Insert Link', '', () => tabManager.wrapSelection('[', '](url)'));
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)')); commandPalette.register('Insert Image', '', () => tabManager.wrapSelection('![', '](image.jpg)'));
commandPalette.register('Toggle Zen Mode', 'F11', () => zenMode.toggle());
commandPalette.register('Writing Analytics', 'Ctrl+Shift+A', () => getShowAnalyticsModal()(tabManager));
// Ctrl+Shift+P keyboard shortcut for command palette // Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Ctrl+Shift+P — Command Palette
if (e.ctrlKey && e.shiftKey && e.key === 'P') { if (e.ctrlKey && e.shiftKey && e.key === 'P') {
e.preventDefault(); e.preventDefault();
commandPalette.open(); commandPalette.open();
} }
// F11 — Zen Mode
if (e.key === 'F11') {
e.preventDefault();
zenMode.toggle();
}
// Ctrl+Shift+A — Writing Analytics
if (e.ctrlKey && e.shiftKey && e.key === 'A') {
e.preventDefault();
getShowAnalyticsModal()(tabManager);
}
// Escape — Exit Zen Mode
if (e.key === 'Escape' && zenMode.active) {
zenMode.deactivate();
}
}); });
// Initialize CodeMirror for the initial tab (tab 1) // Initialize CodeMirror for the initial tab (tab 1)
@@ -1640,9 +1740,11 @@ document.addEventListener('DOMContentLoaded', () => {
tabManager.updatePreview(tab.id); tabManager.updatePreview(tab.id);
tabManager.updateWordCount(); tabManager.updateWordCount();
tabManager.updateTabBar(); tabManager.updateTabBar();
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
}, },
onUpdate: (view) => { onUpdate: (view) => {
tabManager.updateCursorPosition(view); tabManager.updateCursorPosition(view);
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
}, },
isDark, isDark,
showLineNumbers: tabManager.showLineNumbers, showLineNumbers: tabManager.showLineNumbers,
@@ -1664,7 +1766,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Auto-save logic for all tabs // Auto-save logic for all tabs
tabManager.tabs.forEach(tab => { tabManager.tabs.forEach(tab => {
if (tab.isDirty && tab.filePath) { if (tab.isDirty && tab.filePath) {
ipcRenderer.send('save-current-file', tab.content); ipcRenderer.send('save-current-file', { filePath: tab.filePath, content: tab.content });
} }
}); });
}, 30000); }, 30000);
@@ -1688,7 +1790,7 @@ ipcRenderer.on('file-save', () => {
const currentContent = tabManager.getCurrentContent(); const currentContent = tabManager.getCurrentContent();
const currentFilePath = tabManager.getCurrentFilePath(); const currentFilePath = tabManager.getCurrentFilePath();
// send to main process which will save or trigger save-as dialog // send to main process which will save or trigger save-as dialog
ipcRenderer.send('save-current-file', currentContent); ipcRenderer.send('save-current-file', { filePath: currentFilePath, content: currentContent });
}); });
ipcRenderer.on('get-content-for-save', (event, filePath) => { ipcRenderer.on('get-content-for-save', (event, filePath) => {
@@ -1805,7 +1907,7 @@ function openPrintPreviewDialog() {
const previewContent = document.getElementById(`preview-${activeTabId}`); const previewContent = document.getElementById(`preview-${activeTabId}`);
if (!previewContent || !previewContent.innerHTML.trim()) { if (!previewContent || !previewContent.innerHTML.trim()) {
alert('Nothing to print. Please create or open a document and ensure the preview is visible.'); notifyUser('Nothing to print. Create or open a document and keep the preview visible.', 'warning');
return; return;
} }
@@ -2047,7 +2149,7 @@ function saveCurrentProfile() {
// Select the newly created profile // Select the newly created profile
document.getElementById('export-profile-select').value = profileName; document.getElementById('export-profile-select').value = profileName;
alert(`Profile "${profileName}" saved successfully!`); notifyUser(`Profile "${profileName}" saved successfully.`, 'success');
} }
function loadProfile(profileName) { function loadProfile(profileName) {
@@ -2087,7 +2189,7 @@ function deleteSelectedProfile() {
const profileName = select.value; const profileName = select.value;
if (!profileName) { if (!profileName) {
alert('Please select a profile to delete.'); notifyUser('Select a profile to delete.', 'warning');
return; return;
} }
@@ -2096,7 +2198,7 @@ function deleteSelectedProfile() {
saveExportProfiles(); saveExportProfiles();
populateProfileDropdown(); populateProfileDropdown();
select.value = ''; select.value = '';
alert(`Profile "${profileName}" deleted successfully!`); notifyUser(`Profile "${profileName}" deleted successfully.`, 'success');
} }
} }
@@ -2790,7 +2892,8 @@ document.addEventListener('DOMContentLoaded', () => {
const includeSubfolders = document.getElementById('converter-batch-subfolders').checked; const includeSubfolders = document.getElementById('converter-batch-subfolders').checked;
if (!inputFolder || !outputFolder) { if (!inputFolder || !outputFolder) {
alert('Please select both input and output folders for batch conversion'); document.getElementById('converter-progress').classList.remove('hidden');
document.getElementById('converter-status').textContent = 'Select both input and output folders for batch conversion.';
return; return;
} }
@@ -2812,7 +2915,8 @@ document.addEventListener('DOMContentLoaded', () => {
const filePath = converterFilePath; const filePath = converterFilePath;
if (!filePath) { if (!filePath) {
alert('Please select a file to convert'); document.getElementById('converter-progress').classList.remove('hidden');
document.getElementById('converter-status').textContent = 'Select a file to convert.';
return; return;
} }
@@ -2960,7 +3064,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
function hidePDFEditorDialog() { function hidePDFEditorDialog() {
window.modals.pdfEditorModal.close(); window.modals.pdfEditorModal.close();
clearPDFStatus();
document.getElementById('pdf-progress').classList.add('hidden'); document.getElementById('pdf-progress').classList.add('hidden');
document.getElementById('pdf-progress-text').textContent = 'Processing...';
const progressFill = document.getElementById('pdf-progress-fill');
if (progressFill) {
progressFill.style.width = '0%';
}
currentPDFOperation = null; currentPDFOperation = null;
} }
@@ -3161,7 +3271,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentOrder.addEventListener('click', () => { loadCurrentOrder.addEventListener('click', () => {
const inputPath = document.getElementById('reorder-input-path').value; const inputPath = document.getElementById('reorder-input-path').value;
if (!inputPath) { if (!inputPath) {
alert('Please select a PDF file first'); showPDFValidationMessage('Select a PDF file before loading the current page order.', '#reorder-input-path');
return; return;
} }
// Request page count from main process // Request page count from main process
@@ -3178,7 +3288,7 @@ ipcRenderer.on('pdf-folder-selected', (event, { inputId, path }) => {
// Handle PDF page count response // Handle PDF page count response
ipcRenderer.on('pdf-page-count', (event, { count, error }) => { ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
if (error) { if (error) {
alert('Error reading PDF: ' + error); showPDFStatus(`Error reading PDF: ${error}`, 'warning');
return; return;
} }
@@ -3188,6 +3298,39 @@ ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
document.getElementById('reorder-pages').value = currentOrder; document.getElementById('reorder-pages').value = currentOrder;
}); });
function getPDFStatusElement() {
return document.getElementById('pdf-status-message');
}
function showPDFStatus(message, type = 'info') {
const status = getPDFStatusElement();
if (!status) return;
status.textContent = message;
status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message');
status.classList.add(`${type}-message`);
}
function clearPDFStatus() {
const status = getPDFStatusElement();
if (!status) return;
status.textContent = '';
status.classList.remove('info-message', 'warning-message', 'success-message');
status.classList.add('hidden');
}
function showPDFValidationMessage(message, focusSelector = null) {
showPDFStatus(message, 'warning');
if (focusSelector) {
const field = document.querySelector(focusSelector);
if (field && typeof field.focus === 'function') {
field.focus();
}
}
}
// Process PDF Operation // Process PDF Operation
function processPDFOperation() { function processPDFOperation() {
const operation = currentPDFOperation; const operation = currentPDFOperation;
@@ -3197,13 +3340,13 @@ function processPDFOperation() {
switch (operation) { switch (operation) {
case 'merge': case 'merge':
if (mergeFilePaths.length < 2) { if (mergeFilePaths.length < 2) {
alert('Please add at least 2 PDF files to merge'); showPDFValidationMessage('Add at least 2 PDF files to merge first.', '#add-merge-file');
return; return;
} }
operationData.inputFiles = mergeFilePaths; operationData.inputFiles = mergeFilePaths;
operationData.outputPath = document.getElementById('merge-output-path').value.trim(); operationData.outputPath = document.getElementById('merge-output-path').value.trim();
if (!operationData.outputPath) { if (!operationData.outputPath) {
alert('Please select an output file path'); showPDFValidationMessage('Select an output file path.', '#merge-output-path');
return; return;
} }
break; break;
@@ -3214,7 +3357,7 @@ function processPDFOperation() {
operationData.splitMode = document.getElementById('split-mode').value; operationData.splitMode = document.getElementById('split-mode').value;
if (!operationData.inputPath || !operationData.outputFolder) { if (!operationData.inputPath || !operationData.outputFolder) {
alert('Please select input file and output folder'); showPDFValidationMessage('Select both an input PDF and an output folder.', '#split-input-path');
return; return;
} }
@@ -3237,7 +3380,10 @@ function processPDFOperation() {
operationData.optimizeFonts = document.getElementById('compress-optimize-fonts').checked; operationData.optimizeFonts = document.getElementById('compress-optimize-fonts').checked;
if (!operationData.inputPath || !operationData.outputPath) { if (!operationData.inputPath || !operationData.outputPath) {
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file paths')); showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#compress-input-path'
);
return; return;
} }
break; break;
@@ -3250,7 +3396,10 @@ function processPDFOperation() {
operationData.angle = parseInt(document.getElementById('rotate-angle').value); operationData.angle = parseInt(document.getElementById('rotate-angle').value);
if (!operationData.inputPath || !operationData.outputPath) { if (!operationData.inputPath || !operationData.outputPath) {
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file')); showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#rotate-input-path'
);
return; return;
} }
break; break;
@@ -3262,7 +3411,7 @@ function processPDFOperation() {
operationData.pages = document.getElementById('delete-pages').value.trim(); operationData.pages = document.getElementById('delete-pages').value.trim();
if (!operationData.inputPath || !operationData.outputPath || !operationData.pages) { if (!operationData.inputPath || !operationData.outputPath || !operationData.pages) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and pages to delete.', '#delete-input-path');
return; return;
} }
break; break;
@@ -3274,7 +3423,7 @@ function processPDFOperation() {
operationData.newOrder = document.getElementById('reorder-pages').value.trim(); operationData.newOrder = document.getElementById('reorder-pages').value.trim();
if (!operationData.inputPath || !operationData.outputPath || !operationData.newOrder) { if (!operationData.inputPath || !operationData.outputPath || !operationData.newOrder) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and new page order.', '#reorder-input-path');
return; return;
} }
break; break;
@@ -3295,7 +3444,7 @@ function processPDFOperation() {
} }
if (!operationData.inputPath || !operationData.outputPath || !operationData.text) { if (!operationData.inputPath || !operationData.outputPath || !operationData.text) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and watermark text.', '#watermark-input-path');
return; return;
} }
break; break;
@@ -3320,7 +3469,7 @@ function processPDFOperation() {
}; };
if (!operationData.inputPath || !operationData.outputPath || !operationData.userPassword) { if (!operationData.inputPath || !operationData.outputPath || !operationData.userPassword) {
alert('Please select file and enter a user password'); showPDFValidationMessage('Select a file, output path, and user password.', '#encrypt-input-path');
return; return;
} }
break; break;
@@ -3332,7 +3481,7 @@ function processPDFOperation() {
operationData.password = document.getElementById('decrypt-password').value; operationData.password = document.getElementById('decrypt-password').value;
if (!operationData.inputPath || !operationData.outputPath || !operationData.password) { if (!operationData.inputPath || !operationData.outputPath || !operationData.password) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and password.', '#decrypt-input-path');
return; return;
} }
break; break;
@@ -3356,21 +3505,26 @@ function processPDFOperation() {
}; };
if (!operationData.inputPath || !operationData.outputPath || !operationData.ownerPassword) { if (!operationData.inputPath || !operationData.outputPath || !operationData.ownerPassword) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and owner password.', '#permissions-input-path');
return; return;
} }
break; break;
} }
clearPDFStatus();
// Show progress // Show progress
document.getElementById('pdf-progress').classList.remove('hidden'); document.getElementById('pdf-progress').classList.remove('hidden');
document.getElementById('pdf-progress-text').textContent = 'Processing PDF...'; document.getElementById('pdf-progress-text').textContent = 'Processing PDF...';
const progressFill = document.getElementById('pdf-progress-fill');
if (progressFill) {
progressFill.style.width = '10%';
}
// Send to main process // Send to main process
ipcRenderer.send('process-pdf-operation', operationData); ipcRenderer.send('process-pdf-operation', operationData);
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showPDFStatus(`Error: ${error.message}`, 'warning');
console.error('PDF operation error:', error); console.error('PDF operation error:', error);
} }
} }
@@ -3380,10 +3534,14 @@ ipcRenderer.on('pdf-operation-complete', (event, { success, error, message }) =>
document.getElementById('pdf-progress').classList.add('hidden'); document.getElementById('pdf-progress').classList.add('hidden');
if (success) { if (success) {
alert(message || 'PDF operation completed successfully!'); showPDFStatus(message || 'PDF operation completed successfully.', 'success');
setTimeout(() => {
if (window.modals?.pdfEditorModal?.isOpen()) {
hidePDFEditorDialog(); hidePDFEditorDialog();
}
}, 800);
} else { } else {
alert('Error: ' + (error || 'PDF operation failed')); showPDFStatus(`Error: ${error || 'PDF operation failed'}`, 'warning');
} }
}); });
@@ -3715,13 +3873,13 @@ function insertGeneratedTable() {
const table = document.getElementById('table-preview').textContent; const table = document.getElementById('table-preview').textContent;
if (!table) { if (!table) {
alert('Please generate a table preview first'); notifyUser('Generate a table preview first.', 'warning');
return; return;
} }
// Insert table using CodeMirror // Insert table using CodeMirror
if (!tabManager) { if (!tabManager) {
alert('No active editor found'); notifyUser('No active editor found.', 'warning');
return; return;
} }
@@ -4189,13 +4347,13 @@ function insertASCIIArt() {
const asciiArt = document.getElementById('ascii-preview').textContent; const asciiArt = document.getElementById('ascii-preview').textContent;
if (!asciiArt || asciiArt === 'Select a template from the buttons above') { if (!asciiArt || asciiArt === 'Select a template from the buttons above') {
alert('Please generate ASCII art first'); notifyUser('Generate ASCII art first.', 'warning');
return; return;
} }
// Insert using CodeMirror // Insert using CodeMirror
if (!tabManager) { if (!tabManager) {
alert('No active editor found'); notifyUser('No active editor found.', 'warning');
return; return;
} }
@@ -4618,14 +4776,25 @@ document.addEventListener('DOMContentLoaded', () => {
// ============================================ // ============================================
ipcRenderer.on('show-image-tool', (event, tool) => { ipcRenderer.on('show-image-tool', (event, tool) => {
alert(`Image ${tool} tool requires ImageMagick to be installed.\n\nPlease install ImageMagick from: https://imagemagick.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Image ${tool} requires ImageMagick. Install it from imagemagick.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
ipcRenderer.on('show-audio-tool', (event, tool) => { ipcRenderer.on('show-audio-tool', (event, tool) => {
alert(`Audio ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Audio ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
ipcRenderer.on('show-video-tool', (event, tool) => { ipcRenderer.on('show-video-tool', (event, tool) => {
alert(`Video ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Video ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
+114
View File
@@ -0,0 +1,114 @@
/**
* Document Outline Panel
* Parses markdown headings and renders a navigable tree in the sidebar.
*/
function renderOutlinePanel(container, { getEditorContent, getActiveLine, onHeadingClick }) {
container.innerHTML = `
<div class="outline-panel">
<div class="outline-list" id="outline-list"></div>
</div>
`;
const listEl = document.getElementById('outline-list');
let headings = [];
let activeLine = 1;
let debounceTimer = null;
function parseHeadings(content) {
const result = [];
if (!content) return result;
const lines = content.split('\n');
const regex = /^(#{1,6})\s+(.+)$/;
for (let i = 0; i < lines.length; i++) {
const match = regex.exec(lines[i]);
if (match) {
result.push({
level: match[1].length,
text: match[2].trim(),
line: i + 1
});
}
}
return result;
}
function findActiveHeading(currentLine) {
let active = null;
for (const h of headings) {
if (h.line <= currentLine) {
active = h;
} else {
break;
}
}
return active;
}
function renderHeadings() {
const content = getEditorContent();
headings = parseHeadings(content);
activeLine = getActiveLine();
if (headings.length === 0) {
listEl.innerHTML = `
<div class="outline-empty">
<p>No headings found</p>
<p class="outline-hint"># Heading 1</p>
<p class="outline-hint">## Heading 2</p>
<p class="outline-hint">### Heading 3</p>
</div>
`;
return;
}
const activeHeading = findActiveHeading(activeLine);
listEl.innerHTML = headings.map((h, idx) => `
<div class="outline-item outline-level-${h.level}${activeHeading && h.line === activeHeading.line ? ' active' : ''}"
data-line="${h.line}" data-index="${idx}">
<span class="outline-text">${escapeHtml(h.text)}</span>
<span class="outline-badge">H${h.level}</span>
</div>
`).join('') + `<div class="outline-footer">${headings.length} heading${headings.length !== 1 ? 's' : ''}</div>`;
listEl.querySelectorAll('.outline-item').forEach(item => {
item.addEventListener('click', () => {
const line = parseInt(item.dataset.line, 10);
onHeadingClick(line);
});
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function refresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderHeadings, 300);
}
function setActiveHeading(line) {
activeLine = line;
const activeHeading = findActiveHeading(line);
listEl.querySelectorAll('.outline-item').forEach(item => {
const itemLine = parseInt(item.dataset.line, 10);
if (activeHeading && itemLine === activeHeading.line) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
}
container._refreshOutline = refresh;
container._setActiveHeading = setActiveHeading;
renderHeadings();
}
module.exports = { renderOutlinePanel };
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* ConcreteInfo Theme for MarkdownConverter * ConcreteInfo Theme for MarkdownConverter
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09 * Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
* Version: 3.0.0 * Version: 4.1.0
*/ */
/* ============================================ /* ============================================
+26
View File
@@ -2346,3 +2346,29 @@ body[class*="dark"] .breadcrumb-bar {
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
} }
/* Writing Analytics Modal */
.analytics-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; z-index:10000; animation:analyticsFadeIn 0.2s ease; }
.analytics-modal { background:var(--bg-primary,#fff); border-radius:12px; width:520px; max-height:80vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.3); animation:analyticsSlideUp 0.3s ease; }
.analytics-header { display:flex; justify-content:space-between; align-items:center; padding:20px 24px; border-bottom:1px solid var(--border-color,#e5e7eb); }
.analytics-header h2 { margin:0; font-size:18px; font-weight:600; color:var(--text-primary); }
.analytics-close { background:none; border:none; font-size:24px; cursor:pointer; color:var(--text-muted); padding:4px 8px; border-radius:4px; }
.analytics-close:hover { background:var(--bg-tertiary); color:var(--text-primary); }
.analytics-body { padding:16px 24px 24px; }
.analytics-section { margin-bottom:20px; }
.analytics-section h3 { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-muted,#9ca3af); margin:0 0 10px; }
.analytics-row { display:flex; justify-content:space-between; align-items:baseline; padding:6px 0; font-size:14px; }
.analytics-label { color:var(--text-secondary,#6b7280); }
.analytics-value { font-weight:500; color:var(--text-primary); }
.analytics-value small { color:var(--text-muted); font-weight:400; margin-left:6px; }
.readability-meter { height:4px; background:var(--bg-tertiary,#f3f4f6); border-radius:2px; margin-top:8px; overflow:hidden; }
.readability-fill { height:100%; background:linear-gradient(90deg,#ef4444,#f59e0b,#10b981); border-radius:2px; transition:width 0.5s ease; }
.word-cloud { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.word-tag { background:var(--bg-tertiary,#f3f4f6); padding:4px 10px; border-radius:12px; font-size:13px; color:var(--text-secondary); }
.word-tag small { opacity:0.5; font-size:10px; margin-left:2px; }
.analytics-longest { flex-direction:column; gap:4px; }
.analytics-sentence-preview { font-style:italic; font-size:13px; color:var(--text-secondary); font-weight:400; }
@keyframes analyticsFadeIn { from{opacity:0} to{opacity:1} }
@keyframes analyticsSlideUp { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} }
body[class*="dark"] .analytics-modal { background:var(--gray-800,#1f2937); }
body[class*="dark"] .word-tag { background:var(--gray-700,#374151); }
+26
View File
@@ -276,3 +276,29 @@ body[class*="dark"] .tree-item:hover,
body[class*="dark"] .git-file:hover { body[class*="dark"] .git-file:hover {
background: #333; background: #333;
} }
/* Outline Panel */
.outline-panel { display: flex; flex-direction: column; height: 100%; }
.outline-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.outline-item { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; cursor: pointer; font-size: 13px; color: var(--text-secondary, #6b7280); transition: background 0.15s, color 0.15s; border-left: 2px solid transparent; }
.outline-item:hover { background: var(--bg-tertiary, #f3f4f6); color: var(--text-primary, #1f2937); }
.outline-item.active { color: var(--accent-blue, #3b82f6); background: rgba(59, 130, 246, 0.08); border-left-color: var(--accent-blue, #3b82f6); font-weight: 600; }
.outline-level-1 { padding-left: 12px; }
.outline-level-2 { padding-left: 24px; }
.outline-level-3 { padding-left: 36px; }
.outline-level-4 { padding-left: 48px; }
.outline-level-5 { padding-left: 56px; }
.outline-level-6 { padding-left: 64px; }
.outline-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.outline-badge { font-size: 10px; opacity: 0.5; margin-left: 8px; flex-shrink: 0; }
.outline-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-muted, #9ca3af); text-align: center; }
.outline-empty p { margin: 4px 0; }
.outline-hint { font-family: monospace; font-size: 12px; opacity: 0.7; }
.outline-footer { padding: 8px 12px; border-top: 1px solid var(--border-color, #e5e7eb); font-size: 11px; color: var(--text-muted, #9ca3af); }
/* Outline dark mode */
body[class*="dark"] .outline-item { color: #9ca3af; }
body[class*="dark"] .outline-item:hover { background: #374151; color: #e5e7eb; }
body[class*="dark"] .outline-item.active { color: #8b9aff; background: rgba(139, 154, 255, 0.1); border-left-color: #8b9aff; }
body[class*="dark"] .outline-empty { color: #6b7280; }
body[class*="dark"] .outline-footer { border-color: #333; color: #6b7280; }
+102
View File
@@ -0,0 +1,102 @@
/* Zen Mode — Distraction-free writing styles */
/* Hide chrome elements */
body.zen-mode .app-header,
body.zen-mode .tab-bar,
body.zen-mode .toolbar,
body.zen-mode .status-bar,
body.zen-mode .sidebar,
body.zen-mode .breadcrumb-bar {
display: none !important;
}
/* Main content fills viewport */
body.zen-mode .main-content {
height: 100vh;
overflow: hidden;
}
/* Editor container takes full width */
body.zen-mode .editor-container {
width: 100% !important;
height: 100% !important;
}
/* Hide preview panes */
body.zen-mode .preview-container,
body.zen-mode .preview-pane,
body.zen-mode .pane-resizer {
display: none !important;
}
/* Center and style editor content for comfortable reading width */
body.zen-mode .cm-content {
max-width: 700px;
margin: 0 auto;
font-size: 18px;
line-height: 1.8;
padding-top: 80px;
}
body.zen-mode .cm-line {
padding: 4px 0;
}
body.zen-mode .editor-wrapper {
display: flex;
justify-content: center;
}
/* Floating HUD pill */
.zen-hud {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: rgba(255, 255, 255, 0.8);
padding: 10px 24px;
border-radius: 24px;
font-size: 13px;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
z-index: 9999;
pointer-events: none;
display: flex;
align-items: center;
gap: 16px;
transition: opacity 0.3s ease;
}
.zen-hud-stat {
opacity: 0.7;
}
.zen-hud-sep {
opacity: 0.3;
margin: 0 4px;
}
/* Word goal progress bar */
.zen-progress-container {
width: 120px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
margin-top: 8px;
overflow: hidden;
display: none;
}
.zen-progress-bar {
height: 100%;
background: rgba(255, 178, 70, 0.85);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Dark mode — HUD stays dark in both themes */
body[class*="dark"].zen-mode .zen-hud {
background: rgba(0, 0, 0, 0.7);
}
+48
View File
@@ -522,6 +522,54 @@ body.dark .pdf-tab-container .pdf-controls {
flex-shrink: 0; flex-shrink: 0;
} }
.app-toast-container {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: var(--z-toast, 500);
pointer-events: none;
}
.app-toast {
min-width: 260px;
max-width: 420px;
padding: 12px 14px;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
color: #111827;
background: #ffffff;
border-left: 4px solid #3b82f6;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: auto;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.app-toast.visible {
opacity: 1;
transform: translateY(0);
}
.app-toast-info {
border-left-color: #2563eb;
}
.app-toast-success {
border-left-color: #059669;
background: #ecfdf5;
}
.app-toast-warning {
border-left-color: #d97706;
background: #fffbeb;
}
/* Dark theme adjustments for states */ /* Dark theme adjustments for states */
body[class*="dark"] .preview-error-message { body[class*="dark"] .preview-error-message {
color: #9ca3af; color: #9ca3af;
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Modal System Styles * Modal System Styles
* Unified modal components with glassmorphism backdrop * Unified modal components with glassmorphism backdrop
* @version 4.0.0 * @version 4.1.0
*/ */
/* ============================================ /* ============================================
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* ModalManager - Unified modal system with accessibility support * ModalManager - Unified modal system with accessibility support
* @version 4.0.0 * @version 4.1.0
*/ */
class ModalManager { class ModalManager {
#modal; #modal;
+2 -2
View File
@@ -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.0.0</p> <p class="welcome-version">Version 4.1.0</p>
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p> <p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
</div> </div>
@@ -45,7 +45,7 @@ function createWelcomeContent(recentFiles = []) {
</div> </div>
<div class="welcome-section"> <div class="welcome-section">
<h2>What's New in v4.0.0</h2> <h2>What's New in v4.x</h2>
<ul class="welcome-features"> <ul class="welcome-features">
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li> <li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li> <li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
+292
View File
@@ -0,0 +1,292 @@
/**
* Zen Mode — distraction-free writing mode
* Provides typewriter scrolling, line dimming, centered content, and a floating HUD.
* Toggle with F11, exit with Escape.
*/
const {
EditorView,
ViewPlugin,
Decoration,
DecorationSet,
} = require('@codemirror/view');
const { RangeSetBuilder } = require('@codemirror/state');
class ZenMode {
/**
* @param {import('./renderer').TabManager} tabManager
*/
constructor(tabManager) {
this.tabManager = tabManager;
this.active = false;
this._hud = null;
this._timerInterval = null;
this._sessionStart = null;
this._extensions = [];
}
activate() {
if (this.active) return;
this.active = true;
document.body.classList.add('zen-mode');
// Collapse sidebar
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.classList.add('collapsed');
// Hide preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.add('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.add('full-width');
// Add CM6 extensions to the active editor
if (tab.editorView) {
this._extensions = [
typewriterScrollExtension,
lineDimmingExtension,
];
// Reconfigure is not straightforward with CM6's immutable state.
// Instead we dispatch effects or simply rebuild with new extensions.
// Since we cannot hot-swap extensions on an existing EditorView,
// we store a reference and the CSS + updateListener handle the rest.
// The typewriter + dimming plugins are applied via a compartment approach
// would be ideal, but for simplicity we use DOM + updateListener.
this._applyTypewriterBehavior(tab.editorView);
}
}
// Create HUD
this._createHUD();
// Start session timer
this._sessionStart = Date.now();
this._timerInterval = setInterval(() => this._updateHUD(), 1000);
// Focus editor
if (tab?.editorView) tab.editorView.focus();
}
deactivate() {
if (!this.active) return;
this.active = false;
document.body.classList.remove('zen-mode');
// Restore preview pane for the active tab
const tab = this.tabManager.tabs.get(this.tabManager.activeTabId);
if (tab) {
const previewContainer = document.getElementById(`preview-pane-${tab.id}`);
if (previewContainer) previewContainer.classList.remove('hidden');
const editorPane = document.getElementById(`editor-pane-${tab.id}`);
if (editorPane) editorPane.classList.remove('full-width');
// Remove typewriter listener
if (tab.editorView && this._selectionListener) {
window.removeEventListener('zen-typewriter', this._selectionListener);
this._selectionListener = null;
}
}
// Remove HUD
if (this._hud && this._hud.parentNode) {
this._hud.parentNode.removeChild(this._hud);
this._hud = null;
}
// Stop timer
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this._sessionStart = null;
this._extensions = [];
}
toggle() {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
}
_applyTypewriterBehavior(view) {
// We use a custom update listener that scrolls the cursor to center.
// This is stored on the instance so we can clean it up.
let scrollTimeout = null;
const scrollFn = () => {
if (!this.active) return;
const pos = view.state.selection.main.head;
requestAnimationFrame(() => {
view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
});
};
// Listen for selection/doc changes via a polling approach
// since we cannot add extensions to an existing view.
// Instead, use the DOM scrollIntoView after selection changes.
const observer = new MutationObserver(() => {
// No-op, we use interval below
});
// Use interval to keep cursor centered during typing
this._scrollInterval = setInterval(() => {
if (!this.active) {
clearInterval(this._scrollInterval);
this._scrollInterval = null;
return;
}
// Typewriter scroll: center the cursor line
try {
const pos = view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
const scroller = view.scrollDOM;
if (coords && scroller) {
const scrollerRect = scroller.getBoundingClientRect();
const cursorCenter = coords.top + (coords.bottom - coords.top) / 2;
const viewCenter = scrollerRect.top + scrollerRect.height / 2;
const diff = cursorCenter - viewCenter;
if (Math.abs(diff) > 30) {
scroller.scrollTop += diff;
}
}
} catch (e) {
// Ignore errors from destroyed views
}
}, 200);
// Initial scroll to center
scrollFn();
}
_createHUD() {
const hud = document.createElement('div');
hud.className = 'zen-hud';
hud.innerHTML = `
<span class="zen-hud-item" id="zen-words">0 words</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
<span class="zen-hud-sep">&middot;</span>
<span class="zen-hud-item" id="zen-timer">00:00</span>
<div class="zen-progress-container" id="zen-progress-container">
<div class="zen-progress-bar" id="zen-progress-bar"></div>
</div>
`;
document.body.appendChild(hud);
this._hud = hud;
this._updateHUD();
}
_updateHUD() {
if (!this.active || !this._hud) return;
const content = this.tabManager.getEditorContent();
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
const readingTime = Math.max(1, Math.ceil(words / 200));
const wordsEl = document.getElementById('zen-words');
const readingEl = document.getElementById('zen-reading-time');
const timerEl = document.getElementById('zen-timer');
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
if (readingEl) readingEl.textContent = `${readingTime} min read`;
// Session timer
if (this._sessionStart) {
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
const seconds = String(elapsed % 60).padStart(2, '0');
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
}
// Word goal progress
const goalStr = localStorage.getItem('zen-word-goal');
const progressBar = document.getElementById('zen-progress-bar');
const progressContainer = document.getElementById('zen-progress-container');
if (progressBar && progressContainer) {
if (goalStr) {
const goal = parseInt(goalStr, 10);
if (goal > 0) {
const pct = Math.min(100, Math.round((words / goal) * 100));
progressBar.style.width = `${pct}%`;
progressContainer.style.display = 'block';
} else {
progressContainer.style.display = 'none';
}
} else {
progressContainer.style.display = 'none';
}
}
}
}
// Typewriter scroll ViewPlugin
const typewriterScrollExtension = ViewPlugin.fromClass(class {
constructor(view) {
this.view = view;
}
update(update) {
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
requestAnimationFrame(() => {
if (this.view.dom && this.view.dom.isConnected) {
this.view.dispatch({
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
}
});
}
}
});
// Line dimming ViewPlugin
function getOpacity(distance) {
if (distance === 0) return 1.0;
if (distance === 1) return 0.7;
if (distance === 2) return 0.6;
if (distance === 3) return 0.45;
return 0.3;
}
const lineDimmingExtension = ViewPlugin.fromClass(class {
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const builder = new RangeSetBuilder();
const pos = view.state.selection.main.head;
const activeLine = view.state.doc.lineAt(pos).number;
const doc = view.state.doc;
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
const line = doc.line(lineNum);
const distance = Math.abs(lineNum - activeLine);
const opacity = getOpacity(distance);
const deco = Decoration.line({
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
});
builder.add(line.from, line.from, deco);
}
return builder.finish();
}
}, {
decorations: v => v.decorations,
});
module.exports = { ZenMode };
+8
View File
@@ -54,6 +54,14 @@ describe('Preload Security', () => {
'save-pasted-image', 'save-pasted-image',
'load-template', 'load-template',
'list-directory', 'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
'open-file-path', 'open-file-path',
'git-status', 'git-status',
'git-stage', 'git-stage',
+9 -1
View File
@@ -17,7 +17,15 @@ global.window.electronAPI = {
setCurrent: jest.fn(), setCurrent: jest.fn(),
saveRecent: jest.fn(), saveRecent: jest.fn(),
clearRecent: jest.fn(), clearRecent: jest.fn(),
rendererReady: jest.fn() rendererReady: jest.fn(),
read: jest.fn(() => Promise.resolve('')),
write: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
ensureDir: jest.fn(() => Promise.resolve()),
exists: jest.fn(() => Promise.resolve(false)),
isDirectory: jest.fn(() => Promise.resolve(false)),
copy: jest.fn(() => Promise.resolve()),
move: jest.fn(() => Promise.resolve())
}, },
theme: { theme: {
get: jest.fn() get: jest.fn()