mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620227307d | ||
|
|
4426b75c6f | ||
|
|
b7e12f7010 | ||
|
|
f0ab54cd60 | ||
|
|
72a7a854d0 | ||
|
|
50d0638c5c | ||
|
|
7a3493e54f | ||
|
|
b1a5784bcc | ||
|
|
24cb99658e | ||
|
|
c042cf4580 |
@@ -23,3 +23,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
run: npm run lint
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
Vendored
+48
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Main Process",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"args": ["."],
|
||||||
|
"outputCapture": "std",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Renderer Process",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "attach",
|
||||||
|
"port": 9222,
|
||||||
|
"webRoot": "${workspaceFolder}/src",
|
||||||
|
"timeout": 30000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug Main + Renderer",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
|
||||||
|
"windows": {
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
|
||||||
|
},
|
||||||
|
"args": [".", "--remote-debugging-port=9222"],
|
||||||
|
"outputCapture": "std",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
},
|
||||||
|
"serverReadyAction": {
|
||||||
|
"pattern": "listening on port ([0-9]+)",
|
||||||
|
"uriFormat": "http://localhost:%s",
|
||||||
|
"action": "debugWithChrome"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
Core application code lives in `src/`. Use `src/main.js` for the Electron main process, `src/preload.js` for the preload bridge, and `src/renderer.js` plus `src/editor/`, `src/sidebar/`, `src/repl/`, and `src/utils/` for renderer-side features. Electron adapter code is in `src/adapters/electron/`. Reusable markdown/document templates live in `src/templates/`. Static assets and icons are in `assets/`. Tests are in `tests/`, and build output goes to `dist/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `npm start`: launch the Electron app locally.
|
||||||
|
- `npm test`: run the Jest suite once.
|
||||||
|
- `npm run test:watch`: rerun tests during local development.
|
||||||
|
- `npm run test:coverage`: generate coverage output.
|
||||||
|
- `npm run lint` / `npm run lint:fix`: check or fix ESLint issues in `src` and `tests`.
|
||||||
|
- `npm run format` / `npm run format:check`: apply or verify Prettier formatting.
|
||||||
|
- `npm run build:linux`, `npm run build:win`, `npm run build:mac`: create platform packages with `electron-builder`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
This repo uses Prettier and ESLint. Follow `.prettierrc`: 2-space indentation, single quotes, semicolons, trailing commas where valid in ES5, and a 100-character line width. Prefer `camelCase` for variables/functions, `PascalCase` for classes, and kebab-case for file names only when already established. Keep module boundaries clear: UI logic in renderer modules, OS/file-system work behind Electron IPC and adapters.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Tests use Jest with `jest-environment-jsdom`. Add new tests under `tests/` with `*.test.js` names, mirroring the feature area when possible, for example `tests/sidebar.test.js` or `tests/print-preview.test.js`. Update or add regression tests for renderer behavior, preload APIs, and utility helpers when fixing bugs. Run `npm test` before opening a PR; use `npm run test:coverage` for larger refactors.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `refactor:`. Keep subjects short and imperative, for example `fix: guard modal cleanup on close`. PRs should describe the user-visible change, note test coverage, link any related issue, and include screenshots or GIFs for UI changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Do not bypass preload boundaries or introduce direct `eval`/dynamic code paths; ESLint already treats these as errors. Export and conversion features depend on external tools such as Pandoc, FFmpeg, ImageMagick, and LibreOffice, so document any new runtime dependency in `README.md` and packaging config.
|
||||||
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
v3.0.0
|
v4.1.0
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-body">
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Readability</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Flesch Reading Ease</span>
|
||||||
|
<span class="analytics-value">${metrics.fleschEase}<small>${metrics.readabilityLabel}</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Grade Level</span>
|
||||||
|
<span class="analytics-value">${metrics.fleschGrade}</span>
|
||||||
|
</div>
|
||||||
|
<div class="readability-meter">
|
||||||
|
<div class="readability-fill" style="width: ${Math.max(0, Math.min(100, metrics.fleschEase))}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Timing</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Reading Time</span>
|
||||||
|
<span class="analytics-value">~${metrics.readingTime} min</span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Speaking Time</span>
|
||||||
|
<span class="analytics-value">~${metrics.speakingTime} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Structure</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Sentences</span>
|
||||||
|
<span class="analytics-value">${metrics.sentenceCount} • Paragraphs: ${metrics.paragraphCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Avg Sentence</span>
|
||||||
|
<span class="analytics-value">${metrics.avgSentenceLength} words</span>
|
||||||
|
</div>
|
||||||
|
${metrics.longestSentenceLength > 0 ? `
|
||||||
|
<div class="analytics-row analytics-longest">
|
||||||
|
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
|
||||||
|
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="analytics-section">
|
||||||
|
<h3>Vocabulary</h3>
|
||||||
|
<div class="analytics-row">
|
||||||
|
<span class="analytics-label">Unique</span>
|
||||||
|
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
|
||||||
|
</div>
|
||||||
|
${metrics.topWords.length > 0 ? `
|
||||||
|
<div class="word-cloud">
|
||||||
|
${metrics.topWords.map(w => {
|
||||||
|
const scale = 13 + Math.round((w.count / maxCount) * 3);
|
||||||
|
return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeBtn = overlay.querySelector('.analytics-close');
|
||||||
|
closeBtn.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const escHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { showAnalyticsModal };
|
||||||
@@ -0,0 +1,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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+208
-39
@@ -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(''));
|
commandPalette.register('Insert Image', '', () => tabManager.wrapSelection(''));
|
||||||
|
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');
|
||||||
hidePDFEditorDialog();
|
setTimeout(() => {
|
||||||
|
if (window.modals?.pdfEditorModal?.isOpen()) {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,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,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
@@ -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
@@ -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">·</span>
|
||||||
|
<span class="zen-hud-item" id="zen-reading-time">0 min read</span>
|
||||||
|
<span class="zen-hud-sep">·</span>
|
||||||
|
<span class="zen-hud-item" id="zen-timer">00:00</span>
|
||||||
|
<div class="zen-progress-container" id="zen-progress-container">
|
||||||
|
<div class="zen-progress-bar" id="zen-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(hud);
|
||||||
|
this._hud = hud;
|
||||||
|
this._updateHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateHUD() {
|
||||||
|
if (!this.active || !this._hud) return;
|
||||||
|
|
||||||
|
const content = this.tabManager.getEditorContent();
|
||||||
|
const words = content.trim() ? content.trim().split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||||
|
const readingTime = Math.max(1, Math.ceil(words / 200));
|
||||||
|
|
||||||
|
const wordsEl = document.getElementById('zen-words');
|
||||||
|
const readingEl = document.getElementById('zen-reading-time');
|
||||||
|
const timerEl = document.getElementById('zen-timer');
|
||||||
|
|
||||||
|
if (wordsEl) wordsEl.textContent = `${words.toLocaleString()} words`;
|
||||||
|
if (readingEl) readingEl.textContent = `${readingTime} min read`;
|
||||||
|
|
||||||
|
// Session timer
|
||||||
|
if (this._sessionStart) {
|
||||||
|
const elapsed = Math.floor((Date.now() - this._sessionStart) / 1000);
|
||||||
|
const minutes = String(Math.floor(elapsed / 60)).padStart(2, '0');
|
||||||
|
const seconds = String(elapsed % 60).padStart(2, '0');
|
||||||
|
if (timerEl) timerEl.textContent = `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word goal progress
|
||||||
|
const goalStr = localStorage.getItem('zen-word-goal');
|
||||||
|
const progressBar = document.getElementById('zen-progress-bar');
|
||||||
|
const progressContainer = document.getElementById('zen-progress-container');
|
||||||
|
if (progressBar && progressContainer) {
|
||||||
|
if (goalStr) {
|
||||||
|
const goal = parseInt(goalStr, 10);
|
||||||
|
if (goal > 0) {
|
||||||
|
const pct = Math.min(100, Math.round((words / goal) * 100));
|
||||||
|
progressBar.style.width = `${pct}%`;
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typewriter scroll ViewPlugin
|
||||||
|
const typewriterScrollExtension = ViewPlugin.fromClass(class {
|
||||||
|
constructor(view) {
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update) {
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
const pos = update.state.selection.main.head;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (this.view.dom && this.view.dom.isConnected) {
|
||||||
|
this.view.dispatch({
|
||||||
|
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line dimming ViewPlugin
|
||||||
|
function getOpacity(distance) {
|
||||||
|
if (distance === 0) return 1.0;
|
||||||
|
if (distance === 1) return 0.7;
|
||||||
|
if (distance === 2) return 0.6;
|
||||||
|
if (distance === 3) return 0.45;
|
||||||
|
return 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineDimmingExtension = ViewPlugin.fromClass(class {
|
||||||
|
constructor(view) {
|
||||||
|
this.decorations = this.buildDecorations(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update) {
|
||||||
|
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
||||||
|
this.decorations = this.buildDecorations(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDecorations(view) {
|
||||||
|
const builder = new RangeSetBuilder();
|
||||||
|
const pos = view.state.selection.main.head;
|
||||||
|
const activeLine = view.state.doc.lineAt(pos).number;
|
||||||
|
const doc = view.state.doc;
|
||||||
|
|
||||||
|
for (let lineNum = 1; lineNum <= doc.lines; lineNum++) {
|
||||||
|
const line = doc.line(lineNum);
|
||||||
|
const distance = Math.abs(lineNum - activeLine);
|
||||||
|
const opacity = getOpacity(distance);
|
||||||
|
const deco = Decoration.line({
|
||||||
|
attributes: { style: `opacity:${opacity};transition:opacity 0.15s ease` },
|
||||||
|
});
|
||||||
|
builder.add(line.from, line.from, deco);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
decorations: v => v.decorations,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { ZenMode };
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user