Compare commits

...
Author SHA1 Message Date
amitwh bb4e874809 docs(claude-md): add tailored CLAUDE.md for react-electron branch
Documents the React 19 + TypeScript + Tailwind/shadcn rewrite: dual
Vite+Electron dev workflow, modular main process, Zustand stores,
typed IPC via preload whitelist, two-stage build pipeline, and
dual test runners (Jest main, Vitest renderer).

Amit Haridas
2026-06-19 23:18:22 +05:30
amitwh 36422a9ab3 feat: complete master feature parity with interactive PDF editing, Reveal.js export dialog, Large File Mode, and scoped CSS 2026-06-15 01:22:17 +05:30
amitwh 7eb90d467a feat: wire batch media converter and document compare, remove coming-soon stubs
- batch.showConverter: open BatchMediaConverterDialog for image/audio/video
  types (uses existing universal-convert-batch handler with ImageMagick/FFmpeg)
- tools.documentCompare: line-by-line diff of two open tabs with confirmation
  dialog, shows diff count in toast and full diff in console
- No more 'coming soon' toasts for any menu item
2026-06-14 01:51:43 +05:30
amitwh 809c266e54 fix: serialize git status/stage/commit/log results for IPC transfer
simple-git returns objects with Map/Set fields that cannot be cloned
over Electron IPC. Flatten all git operation results into plain
JSON-serializable objects to fix 'An object could not be cloned' error.
2026-06-14 01:43:29 +05:30
amitwh 2e3f4dda0f fix: add missing rendererReady call and fix temporal dead zone crash
- App.tsx: call electronAPI.file.rendererReady() on mount so main process
  opens pendingFile (files passed via CLI or file associations)
- FindReplaceBar: move updateMatchCount declaration before executeCommand
  to fix 'Cannot access d before initialization' crash in minified build
  (executeCommand referenced updateMatchCount via closure before its
  const declaration in the same scope)
2026-06-14 01:25:05 +05:30
amitwh 21a00a53a4 fix: resolve 12 critical runtime failures, security issue, and dead code
CRITICAL fixes:
- GitStatusPanel: use ipc.file.gitStage/gitCommit instead of non-existent top-level methods
- HeaderFooterDialog: use send/on channels instead of non-existent headerFooter namespace
- CodeMirrorEditor: use invoke('save-pasted-image') instead of non-existent savePastedImage
- ipc.ts: map to correct preload namespaces (git.status, git.stage, git.commit)
- Add pick-folder/pick-file to ALLOWED_SEND_CHANNELS whitelist
- Add git convenience namespace to preload (git.status/stage/commit/log/diff)

HIGH fixes:
- FindReplaceBar: replace broken match count stub with regex-based counter
- PDF export window: disable nodeIntegration, enable contextIsolation + sandbox
- Git handler: accept rootPath argument from renderer
- Add git-diff IPC handler + GitOperations.diff()

MEDIUM fixes:
- Remove 4 dead functions: checkPandocAvailable, safeExecFile, runPandoc, openPDFFile, exportWithPandocPDF
- Remove stale ODT header/footer console.log stub
- Rewrite electron.d.ts to match actual preload API shape
2026-06-13 19:52:51 +05:30
amitwh f2398e6e1a feat: enhance WelcomeDialog, export dialogs, GitStatusPanel, add CommandPalette, FindReplaceBar, and new converter dialogs
- WelcomeDialog: add quick start, feature showcase, keyboard shortcuts, recent files, version display
- ExportDocxDialog/ExportHtmlDialog/ExportPdfDialog: add header/footer, paper size, margin controls
- GitStatusPanel: full staging/unstaging, discard, commit workflow
- CommandPalette: fuzzy command search with keyboard navigation
- FindReplaceBar: find/replace with regex, case, whole-word options
- New dialogs: BatchMediaConverterDialog, HeaderFooterDialog, PdfEditorDialog, UniversalConverterDialog
- Tests: comprehensive coverage for all new/updated components
2026-06-13 19:34:42 +05:30
amitwh 6b564a4569 style: run prettier formatter over src and tests 2026-06-11 20:46:00 +05:30
amitwh 2389d4f297 feat: add writing analytics dashboard and daily word goal tracking 2026-06-11 20:44:16 +05:30
amitwh 50f4f62575 fix: handle list-directory return shape {entries} in openFolder/loadChildren
The main process list-directory handler returns {path, entries} but
the file store was calling .map() directly on result.data. Since
arrays have a .entries iterator method, the nullish coalescing
fallback didn't work. Now properly extracts the entries array with
Array.isArray check.
2026-06-11 07:49:42 +05:30
amitwh 9ef5317d71 fix: add show-document-compare to preload receive channel whitelist
The IPC channel was missing from ALLOWED_RECEIVE_CHANNELS in preload.js,
causing the useBridgeNativeMenu listener to be silently blocked.
2026-06-11 07:37:48 +05:30
amitwh cf6b6817b9 fix: wire orphaned IPC channels, fix data-loss bug, print preview events, sidebar navigation
- Fix file.clearRecent sending IPC to main process instead of clearing openTabs
- Wire show-batch-converter, show-document-compare, open-header-footer-dialog IPC channels
- Add print preview event listeners (mc:print-preview, mc:print-preview-styled) in App.tsx
- Fix sidebar hidden bridge buttons toggling sidebar closed after scroll
- Update sidebar menu labels to match actual sections (Files, Outline, Git)
- Fix ipc.print to be an object with show/doPrint methods (was bare function)
- Update callers: ExportPdfDialog, pdf-export, PrintPreview
- Add 7 regression tests for all fixes
- Clean 2 obsolete snapshots
- Build Linux packages (deb, AppImage, snap) — 549 tests passing
2026-06-11 07:15:33 +05:30
amitwh e25a5e1d75 fix(migration): normalize legacy theme values under v5 marker
Two related bugs surfaced during end-to-end verification:

1. The 'isAlreadyV5' short-circuit in both the main-side and renderer-side
   v4-to-v5 transforms returned the persisted object as-is when a
   migration.version=5 marker was present. A previously-shipped v4 build
   had written theme: 'ayu-light' (and similar) under the v5 marker;
   the transform trusted the marker and passed the invalid theme through,
   which then failed the renderer's zod schema on every launch and
   reset user settings to defaults.

   Fix: in both transforms, when isAlreadyV5 matches, normalize theme
   against the v5 enum (light/dark/system) and validate the full result
   with the v5 schema before returning. Out-of-range values are replaced
   with the v5 default ('system').

2. The renderer's settings-store onRehydrateStorage callback called
   useSettingsStore.setState() to reset the store on validation failure.
   At that moment the store is still being constructed, and setState
   could hit a TDZ ReferenceError (the one we already wrapped in a
   try/catch in v5.0.0, which only hid the symptom).

   Fix: return the normalized state object from onRehydrateStorage
   instead. Zustand's persist middleware applies the returned value
   *after* construction completes, so there is no TDZ.

Tests: 334 vitest + 208 jest = 542 passing.
E2E: 12/12 verify-features.mjs steps green; no console errors.

Amit Haridas
2026-06-08 07:45:06 +05:30
amitwh c5d4b113bd fix(polish): address 3 review blockers + 1 security issue
1. ThemeSettings: change 'auto' radio value to 'system' to match
   the v5 settingsSchema enum. Storing 'auto' silently wipes all
   settings on next launch.
2. UpdateBanner: render an 'available' branch so users see a CTA
   when a new version is detected (the store already had this state,
   but the banner was silent). Added a test.
3. feed-config: remove the unused resolveFeedUrl / FEEDS exports.
   feedConfigFor is the actual implementation used by the IPC
   handler. Updated tests to match.
4. main/index.js: tighten app:open-external regex to https:// only.
2026-06-08 07:35:54 +05:30
Amit Haridas 1715e26e5f chore: E2E additions to verify-features.mjs (note: .claude/ is gitignored, see script content in working tree) 2026-06-08 07:23:11 +05:30
Amit Haridas 81484a8d33 test(e2e): verify first-run wizard and update banner in live app
- verify-features.mjs: dismiss FirstRunWizard if visible before clicking
  toolbar (wizard overlay blocked previous toolbar clicks); write a
  firstRun=true settings.json to test wizard visibility; emit
  'updater:status' from main via Playwright IPC.
- package.json: dev:electron now passes --no-sandbox to match the
  .claude/skills/run-desktop driver scripts.
2026-06-08 07:23:01 +05:30
amitwh 6df1389cc8 fix(updater): dispatch setFeedURL by provider instead of raw url 2026-06-08 07:11:28 +05:30
amitwh aeadde5f40 fix(migration): handle already-v5 settings and ensure 'failed' branch writes v5 marker 2026-06-08 07:02:45 +05:30
amitwh 74dde8d6d1 docs: v5.1.0 changelog + Distribution & Updates section 2026-06-08 06:49:12 +05:30
amitwh cd57f34c36 ci(release): publish latest.yml on all platforms, mirror to ConcreteInfo 2026-06-08 06:48:00 +05:30
amitwh 4da701deb5 ci: run on react-electron branch and ensure both jest and vitest run 2026-06-08 06:46:11 +05:30
amitwh 41c68fcf82 feat(renderer): auto-check for updates 5s after launch when enabled 2026-06-08 06:45:16 +05:30
amitwh 9b899b5205 feat(renderer): mount UpdateBanner, FirstRunWizard, and CrashReportModal 2026-06-08 06:43:40 +05:30
amitwh 691b00a2b8 feat(settings): add Updates section with channel radio and Check now 2026-06-08 06:40:51 +05:30
amitwh ade6c115b8 feat(renderer): add CrashReportModal listing local dumps 2026-06-08 06:38:08 +05:30
amitwh cc33e6c7a8 feat(renderer): add FirstRunWizard with theme/channel/template steps 2026-06-08 06:34:40 +05:30
amitwh 59fd9b8646 feat(renderer): add UpdateBanner for non-blocking update notifications 2026-06-08 06:31:07 +05:30
amitwh d87f175212 feat(renderer): add updater store mirroring main process state 2026-06-08 06:27:13 +05:30
amitwh ef88b6343b feat(settings): add updateChannel, autoCheckUpdates, firstRun fields 2026-06-08 06:25:58 +05:30
amitwh e8c9f95d25 feat(main): initialize updater, crash writer, and migration on app start 2026-06-08 06:24:34 +05:30
amitwh f85c1a8107 feat(preload): allowlist updater:* and crash:* channels, expose API 2026-06-08 06:20:59 +05:30
amitwh b1b9aa3727 feat(ipc): add updater and crash IPC handlers 2026-06-08 06:19:15 +05:30
amitwh 77ad2fdb9d test(settings): update default theme expectation from 'auto' to 'system' 2026-06-08 06:18:11 +05:30
amitwh c62070304f feat(migration): add v4-to-v5 settings migration runner with backup 2026-06-08 06:16:17 +05:30
amitwh 9f4bfbdfee feat(crash): add CrashWriter for local crash dumps (cap 20, prune oldest) 2026-06-08 06:14:02 +05:30
amitwh 2e41b59da5 feat(updater): add UpdaterService wrapping electron-updater lifecycle 2026-06-08 06:12:04 +05:30
amitwh 57c8f92f42 feat(updater): add feed-config to map channel setting to feed URL 2026-06-08 06:10:06 +05:30
amitwh 9da4b96f76 chore(deps): add electron-updater for auto-update flow 2026-06-08 06:08:35 +05:30
Amit Haridas 5b81988f30 docs(plan): production polish implementation plan
21 tasks, 90 actionable steps. TDD throughout. Each step shows the
exact code and the exact command with expected output.

Tasks:
1. Install electron-updater
2-5. Updater service, crash writer, migration runner, feed config (TDD)
6-7. IPC handlers + preload allowlist
8. Wire services in main entry
9-10. Settings + updater store
11-13. UpdateBanner, FirstRunWizard, CrashReportModal
14-16. Settings sheet updates + App.tsx mounts
17-18. CI + release workflows (publish latest.yml, mirror to CI)
19. Docs
20-21. E2E verification + tag v5.1.0

After this plan executes, the remaining v5+ sub-projects are:
- Cloud/licensing (next spec)
- Plugin system
- Performance + accessibility
2026-06-07 23:47:15 +05:30
Amit Haridas a5da5d19bc docs(spec): production polish design for v5 shippable build
Brainstormed with the user (ULTRATHINK context). Sub-project 1 of 4 in the
v5+ roadmap (production polish → cloud/licensing → plugins → perf+a11y).

Spec covers:
- Two-channel distribution: GitHub Releases (default) + ConcreteInfo self-hosted
- Light, skippable first-run wizard (theme + update channel + starter template)
- Auto-migrate v4.4.1 settings with v4 backup and toast on success/failure
- Local crash dump capture (no third-party) with a CrashReportModal
- Non-blocking update banner with user-confirmed restart-to-install
- IPC allowlist additions for updater:* and crash:*
- GitHub Actions CI extension (latest.yml generation, PR test suite)

Defers to v5.1: code signing, Sentry, delta updates, beta tracks.

After user review and approval, write the implementation plan via the
writing-plans skill.
2026-06-07 23:42:03 +05:30
amitwh 74ff6afb19 feat(renderer): wire remaining stubbed commands and rebuild
The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.

Toolbar format buttons (Toolbar.tsx)
  - Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
    commands instead of being permanently disabled.

New module: src/renderer/lib/editor-commands.ts
  - Module-level singleton holding the active CodeMirror EditorView.
  - toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
    insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
  - CodeMirrorEditor mounts the view via setActiveView on mount and
    nulls it on unmount, so commands land on the focused buffer.

register-menu-commands.ts
  - file.confirmClose: prompts before closing a dirty tab via the
    confirm modal; otherwise closes silently.
  - app.quit: prompts before quitting if any buffer is dirty; uses
    the new ipc.app.quit channel.
  - short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
  - editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
    active editor.
  - find.toggle: dispatches mc:find-toggle for any listening component.
  - view.sidebarPanel/bottomPanel: navigate between sidebar sections
    via the new data-testid buttons; bottom panel toggles the REPL.
  - font.size: increase/decrease/reset on editorFontSize (10..28).
  - theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
    path on the settings store.
  - template.load: inserts a bundled markdown skeleton at the cursor.
  - print.preview/previewStyled: emit mc:print and mc:print-preview.
  - file.clearRecent: clears open tabs.
  - file.new: creates an untitled buffer and tab.
  - editor.gotoHeading: scrolls to a given line (used by Outline and
    Breadcrumb).
  - view.toggleSidebar: already wired.
  - file.opened: already wired (prior fix).

Sidebar Outline + Breadcrumb
  - Both now click through to 'editor.gotoHeading' with the line
    number extracted from the active buffer.
  - Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.

editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.

Export dialogs (PDF/DOCX/HTML)
  - Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
    channels were not implemented in main and only CHANNEL_MISSING
    was returned) with renderer-side pipelines.
  - PDF: generateHtml() with @page CSS for size + margins, then
    ipc.print to the main process for native print-to-PDF.
  - DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
  - HTML: generateHtml() then ipc.file.writeBuffer.
  - All three dialogs use ipc.app.showSaveDialog for output path
    (new IPC handler) and ipc.file.writeBuffer for the binary write
    (new preload channel).
  - PrintPreview now uses MarkdownRenderer instead of a raw <pre>.

New settings fields
  - editorFontSize: 10..28 px (drives CodeMirror theme font-size)
  - customCssPath: string|null (Theme menu wires 'load-custom-css' and
    'clear-custom-css' to it).

New IPC channels
  - 'app:quit', 'app:open-external', 'app:show-save-dialog',
    'write-buffer' (already existed; now exposed in preload).

Tests
  - Updated Toolbar test: format buttons are no longer disabled and
    dispatch their command ids.
  - Updated Export*D*Dialog tests to mock the new ipc surface
    (ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
    assert the new flow.
  - Updated PrintPreview test to use ipc.print.doPrint.
  - Updated phase8-toasts integration: PDF flow now goes through
    ipc.print and asserts 'Sent <title> to printer'.
  - register-menu-commands test: re-register 'template.load' AFTER
    Harness renders so the captured-args handler is the live one.

Verification
  - npm run build:renderer: success (1.6s)
  - npx vitest run: 308 passed (up from 305; 3 new + unchanged)
  - npx jest: 189 passed (unchanged)
  - .claude/skills/run-desktop/verify-features.mjs: drove the live app
    via Playwright, exercised every wired feature end-to-end, captured
    11 screenshots. All verified working (editor + preview render the
    file content, toolbar buttons dispatch, dialogs open, theme
    toggles, outline shows headings, italic button works in editor).
2026-06-07 23:13:47 +05:30
amitwh 5ef1610873 fix(ipc): wire preload bridge so menu Open file actually loads in editor + preview
Two related defects were breaking the 'open file' flow:

1. The BrowserWindow webPreferences had no preload path AND
   contextIsolation: false, so contextBridge.exposeInMainWorld
   threw on load and window.electronAPI was never set. Result:
   every renderer-side ipc.* call returned CHANNEL_MISSING and
   silently no-op'd. Toolbar Open/Save, the file menu, every
   dialog — all broken.

2. The 'file.opened' IPC bridge in register-menu-commands.ts
   dispatched to a 'file.opened' command that was never registered
   as a handler. Even if the preload had worked, the main menu's
   File -> Open would have been a no-op once it reached the
   command store.

Fixes:
- window/index.js: add preload path; set contextIsolation: true
  (required for contextBridge) and nodeIntegration: false
  (renderer no longer needs direct Node access; everything goes
  through the whitelisted preload bridge).
- file-store.ts: add openFileFromMain(path, content) that opens
  a tab + editor buffer from content the main process already read
  (skips the renderer's ipc.file.read since main has the bytes).
  Preserves existing buffer content if the file is already open.
- register-menu-commands.ts: register 'file.opened' to call
  openFileFromMain. Drop the dead 'command.palette' bridge
  (no consumer, no UI, no handler).
- tests: two new cases for openFileFromMain — verifies it does
  not call ipc.file.read and does not clobber user edits on
  re-open.

Verified end-to-end via the run-desktop driver: triggering
file-opened from the main process with a test .md now shows the
content in BOTH the CodeMirror editor and the rendered preview
pane (plus the Outline panel picks up the H1). 497 tests pass.
2026-06-07 22:35:30 +05:30
amitwh 69afd5fc54 chore(release): bump version to 5.0.1 2026-06-06 23:08:20 +05:30
amitwh 9e315af1b9 fix(release): walk-and-find pandoc binary in extracted archive
Pandoc 3.9 macOS zip uses an arch-suffixed inner directory
(pandoc-3.9.0.2-x86_64/) where Linux/Windows use the bare
pandoc-3.9.0.2/ dir. Hard-coding the intermediate path broke
the macOS build in v5.0.1-rc1.

Replace the hard-coded path.join(tmpDir, PANDOC_VERSION, ...)
with a recursive walk that locates the binary by name. This
is robust to future pandoc layout shifts (e.g., universal
binaries renaming the inner dir).

Verified:
  - Linux: download + extract produces a working pandoc 3.9.0.2
  - macOS: walker finds pandoc in pandoc-3.9.0.2-x86_64/bin/
  - Windows: walker finds pandoc.exe in pandoc-3.9.0.2/
  - 306/306 tests pass
2026-06-06 23:07:30 +05:30
amitwh 78914a4d65 chore(renderer): default to light theme
User prefers light mode. next-themes previously defaulted to dark
which made first-load users (no localStorage) see a dark UI. With
'enableSystem' on, 'system' is still available as a choice — only
the initial value changed.

Existing users with 'dark' in localStorage keep dark until they toggle.
New users get light.
2026-06-06 22:43:16 +05:30
amitwh 3c4cc985fb feat(packaging): add macOS icon.icns + build:icon-icns script
electron-builder's mac block declared 'icon: assets/icon.icns' but the
file was never generated, so macOS builds shipped with the default
Electron icon. The build would succeed but the .app looked generic.

Add scripts/build-icon-icns.js — a 50-line node script that packs the
existing PNGs from assets/icons/ (16/32/64/128/256/512/1024 + 48 small
+ 16/32/128/256 @2x retina) into a valid ICNS file. No native deps;
works on Linux, mac, and Windows runners alike (avoids the mac-only
'sips' that png2icns requires).

Wired into npm via 'npm run build:icon-icns'. Local build:linux now
parses the ICNS correctly and produces AppImage/deb/snap.
2026-06-06 22:43:06 +05:30
amitwh 09d9f6bdd1 fix(window): renderer path off-by-one; use 'close' for state save
Two related bugs in src/main/window/index.js:

1. Renderer path was ../../dist/renderer/index.html which resolves to
   src/dist/renderer/index.html (one level too deep). Production builds
   load from <project>/dist/renderer/index.html. Fix: ../../..

   This was never caught because dev mode uses VITE_DEV_SERVER_URL and
   never touches the file path; only 'electron .' (no Vite) hits it.

2. win.on('closed', state.save) calls win.getBounds() on a destroyed
   BrowserWindow. The 'closed' event fires AFTER destruction, so
   getBounds() throws 'Object has been destroyed'. Use 'close' (fires
   before destruction) and guard with isDestroyed() as a belt-and-suspenders.

Both bugs surfaced when the user clicked File → Open. The renderer
loaded a missing HTML (blank body); the openFile handler then triggered
the closed-event state save which threw.
2026-06-06 22:42:54 +05:30
amitwh f36d918871 fix(menu): require('../main') → require('../index') after main.js→index.js rename
The menu items file was using require('../main') which resolved to a
non-existent src/main/main.js. After the 054ce23 refactor that renamed
main.js → index.js, every menu click tried to require a missing module.

All 65 occurrences fixed; fileItems() now loads cleanly at app startup
and the menu doesn't throw on construction.

Caught by user clicking File → Open: showOpenDialogSync emitted through
to the lazy require at items.js:52, which failed with module-not-found
before main process handlers ran.
2026-06-06 22:42:42 +05:30
amitwh ab16998922 feat(packaging): all-platform builds with GitHub Releases
Closes the gap from the previous handoff: `npm run dist:all` now
produces Linux .deb/AppImage/snap, Windows NSIS/portable/zip, and
macOS dmg/zip artifacts. `.github/workflows/release.yml` builds all
three on tag push (v*) and attaches the union to a GitHub Release.

Bug fix in the same commit (would have broken any packaged build):

* `build.files` was `["src/**/*", ...]` — shipped `src/renderer/`
  .tsx dev source into the asar. Replaced with a whitelist
  (`src/main/**/*`, `src/preload.js`, `src/plugins/**/*`,
  `package.json`) and moved the built renderer to
  `build.extraResources` (`dist/renderer` -> `renderer/`).

* `src/main/window/index.js` now uses `process.resourcesPath` when
  `app.isPackaged` is true, plus a friendly error if the renderer is
  missing. Verified by extracting the .AppImage and launching the
  binary — main process logs
  `Production mode — loading .../resources/renderer/index.html`.

New in `package.json` build config:

* `mac.target`: dmg + zip for x64 and arm64; `darkModeSupport: true`,
  `hardenedRuntime: false`, `gatekeeperAssess: false`,
  `entitlements: null`. `mac.identity: null` retained (unsigned).
* `publish`: GitHub Releases provider. CI overrides with
  `--publish=never`.

Known limitations (intentional follow-ups, not blockers):

* No `assets/icon.icns` — macOS app uses the default Electron icon.
  This box lacks `iconutil`/`png2icns`; generating a proper .icns is
  a one-line follow-up.
* No code signing — Windows SmartScreen will warn, macOS Gatekeeper
  will quarantine. `CSC_LINK_BASE64`/`CSC_KEY_PASSWORD` secrets are
  already wired in the Windows job for when the cert is added.
* No auto-update — user decision. The `publish` block writes the
  metadata that a future `electron-updater` integration will consume.

Verified on this Linux box:

* `npm run build:renderer` then `npm run build:linux` — produced
  AppImage (291M), deb (220M), snap (245M).
* `resources/app.asar` has 0 `src/renderer/*` entries (was the
  pre-fix bug). 44 .tsx files remain in asar — all from
  `node_modules/@hookform/resolvers` and `node_modules/immer` test
  fixtures, not our code.
* Extracted `squashfs-root/resources/renderer/index.html` exists and
  is what `window/index.js` loads.
* Binary runs for >12s without crashing — `loadFile` resolves
  `process.resourcesPath/renderer/index.html` correctly.

Tests: 306/306 pass. No regressions.

Plan doc: docs/plans/2026-06-06-packaging-all-platforms.md (the
task-by-task execution guide I followed; included for traceability).
2026-06-06 21:06:20 +05:30
amitwh 58ca3014d8 fix(modals): drop shadcn-style aria-describedby to silence Radix warning
Every DialogContent had aria-describedby="X-desc" and every
DialogDescription had a matching id="X-desc". That pattern is the
default shadcn/ui recipe, but it breaks Radix 1.1.x.

Root cause: Radix auto-generates a descriptionId via useId() (e.g. :r0:)
and stores it in the Dialog context. The DescriptionWarning effect does
`document.getElementById(descriptionId)` and warns if the element is
missing. When the description element's id is overridden to
"welcome-desc", the DOM has id="welcome-desc" but the context still has
:r0:, so the lookup misses and the warning fires on every dialog open.

Fix: drop both overrides. Let Radix manage the id and aria-describedby
itself. Screen-reader semantics are unchanged (Radix still wires
Content -> Description via the auto-generated id).

Touched: 12 modal files (11 DialogContent/DialogDescription pairs +
SettingsSheet which uses Sheet). +1 regression test in
WelcomeDialog.test.tsx that spies on console.warn and asserts the
"Missing Description" message is never emitted.

Tests: 306/306 pass (was 305).

Long-term memory: radix-aria-describedby-anti-pattern.md captures the
do-not-reintroduce rule for future agents.
2026-06-06 20:45:13 +05:30
amitwh 7c1a79c724 fix(app): add Vite dev workflow, fix onLayout prop, fix icon path, allow Google Fonts CSP
The v5.0.0 React UI redesign shipped without a working dev workflow —
`npm start` only ran `electron .` which loaded the source index.html
that references raw .tsx files. The browser couldn't execute them, so
the app rendered an empty #root and the user saw 'lot of errors'.

This commit restores a real dev workflow:

1. **Vite dev server wired into npm scripts** — added `concurrently`
   + `wait-on` as devDeps. `npm run dev` now runs Vite (port 5173)
   and Electron together with `wait-on tcp:5173` so Electron only
   starts after the dev server is ready.

2. **Main process loads Vite URL in dev, dist/ in prod** — window
   module now checks `process.env.VITE_DEV_SERVER_URL` and
   `app.isPackaged` to decide what to load. Production still
   loads the built `dist/renderer/index.html`.

3. **Fixed ReferenceError: app is not defined** — window module
   referenced `app.isPackaged` but only imported `BrowserWindow`.
   Added `app` to the destructured import.

4. **Fixed wrong icon path** — window creation used
   `../../assets/icon.png` from src/main/window/ which resolved
   to `src/assets/icon.png` (doesn't exist). Icon lives at
   project root, so path is now `../../../assets/icon.png`.

5. **Allowed Google Fonts in CSP** — the strict CSP
   (`font-src 'self' data:`, `connect-src 'self'`) blocked
   the preconnect/css link to fonts.googleapis.com / .gstatic.com.
   Added both domains so Plus Jakarta Sans loads.

6. **Renamed onLayout → onLayoutChange** in AppShell — react-resizable-panels
   v4 renamed the prop. The v1 name was being spread to a DOM
   div and React logged 'Unknown event handler property' warnings.
   Test mock updated for parity.

7. **show: false → show: true** — ready-to-show was hanging on
   this Wayland/container combo. Now the window is shown
   immediately on create.

Verified end-to-end:
- 305/305 vitest tests pass
- Vite dev server starts on :5173
- Electron loads localhost:5173 and React mounts
- Header 'MarkdownConverter' + sidebar with 'No files open' visible
- No onLayout warnings after HMR
- All blocked errors resolved
2026-06-06 16:17:16 +05:30
amitwh 8027d0b9b5 chore(release): bump version to 5.0.0 2026-06-06 14:13:52 +05:30
amitwh c0988a0108 docs: add CHANGELOG.md (v5.0.0 entry, Keep a Changelog 1.1.0 format) 2026-06-06 14:12:27 +05:30
amitwh 18fc2317ae refactor: delete 17 legacy files (renderer.js, 8 stylesheets, 5 scripts, 2 htmls, src/index.html orphan, deprecated src/main.js) 2026-06-06 14:11:56 +05:30
amitwh 94dd62946a refactor(preload): remove 9 dead IPC channels (print-preview*, command-palette, open-*/show-* ascii/table) 2026-06-06 14:09:25 +05:30
amitwh 054ce2351a refactor(main): create src/main/{store,index}.js glue layer; new entrypoint at src/main/index.js 2026-06-06 14:08:13 +05:30
amitwh 6651dccfdf refactor(main): move WordTemplateExporter from src/wordTemplateExporter.js to src/main/word-template/ (preserved as single module) 2026-06-06 14:04:16 +05:30
amitwh 848102905e refactor(main): extract window creation to src/main/window/ (main only, ascii/table windows removed) 2026-06-06 14:03:12 +05:30
amitwh 6c7a86c99b refactor(main): extract menu to src/main/menu/ (remove dead print-preview/command-palette/ascii/table sends) 2026-06-06 14:00:29 +05:30
amitwh b46fa9abaa fix(main): remove duplicate list-directory handler from src/main/files/ (kept original in main.js) 2026-06-06 09:52:00 +05:30
amitwh 2a32e702f3 refactor(main): extract file ops to src/main/files/ (search, git, binary) 2026-06-06 09:43:31 +05:30
amitwh 356cfc5cc8 refactor(main): extract path helpers to src/main/utils/paths.js 2026-06-06 09:25:22 +05:30
amitwh a03144df5b docs(plan): Phase 10 polish + delete legacy — 10 tasks, main.js decomposition + 17 legacy file deletions + v5.0.0 2026-06-06 09:22:15 +05:30
amitwh 7fc11aed3b docs(spec): Phase 10 — fix index.html ambiguity (DELETE legacy root src/index.html, not rewrite; src/renderer/index.html is already live) 2026-06-06 09:16:02 +05:30
amitwh 095f77f5f1 docs(spec): Phase 10 polish + delete legacy design — main.js decomposition, file map, v5.0.0 2026-06-06 09:14:39 +05:30
amitwh 80afbb136b test(renderer): Phase 9 tools integration smoke test (6 commands) 2026-06-06 08:38:01 +05:30
288 changed files with 20843 additions and 22382 deletions
+5 -2
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [master] branches: [master, react-electron]
pull_request: pull_request:
branches: [master] branches: [master, react-electron]
jobs: jobs:
test: test:
@@ -24,5 +24,8 @@ jobs:
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Run renderer tests
run: npm run test:renderer
- name: Run linter - name: Run linter
run: npm run lint run: npm run lint
+58 -4
View File
@@ -29,7 +29,7 @@ jobs:
run: npm test run: npm test
- name: Build Linux packages - name: Build Linux packages
run: npm run build:linux-ci -- --publish=never run: npm run build:linux-ci -- --publish=always
- name: Upload Linux artifacts - name: Upload Linux artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -77,13 +77,13 @@ jobs:
env: env:
CSC_LINK: code-signing-cert.pfx CSC_LINK: code-signing-cert.pfx
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run build:win-signed -- --publish=never run: npm run build:win-signed -- --publish=always
- name: Build Windows packages (unsigned) - name: Build Windows packages (unsigned)
if: ${{ env.CERT_AVAILABLE != 'true' }} if: ${{ env.CERT_AVAILABLE != 'true' }}
env: env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false' CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build:win-unsigned -- --publish=never run: npm run build:win-unsigned -- --publish=always
- name: Upload Windows artifacts - name: Upload Windows artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -94,8 +94,42 @@ jobs:
dist/*.zip dist/*.zip
retention-days: 5 retention-days: 5
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Run tests
run: npm test
- name: Build macOS packages (unsigned)
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build:mac -- --publish=always
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos-artifacts
path: |
dist/*.dmg
dist/*.zip
retention-days: 5
release: release:
needs: [build-linux, build-windows] needs: [build-linux, build-windows, build-macos]
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -115,8 +149,28 @@ jobs:
name: windows-artifacts name: windows-artifacts
path: dist path: dist
- name: Download macOS artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: macos-artifacts
path: dist
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
generate_release_notes: true generate_release_notes: true
files: dist/* files: dist/*
- name: Mirror artifacts to ConcreteInfo update feed
if: env.CONCRETEINFO_DEPLOY_HOOK != ''
env:
CONCRETEINFO_DEPLOY_HOOK: ${{ secrets.CONCRETEINFO_DEPLOY_HOOK }}
run: |
curl -fsSL -X POST \
-H "Authorization: Bearer ${{ secrets.CONCRETEINFO_DEPLOY_HOOK }}" \
-F "version=${GITHUB_REF_NAME#v}" \
-F "artifacts=@dist/latest-mac.yml" \
-F "artifacts=@dist/latest-linux.yml" \
-F "artifacts=@dist/latest-windows.yml" \
https://updates.concreteinfo.co.in/api/v1/ingest
+52
View File
@@ -0,0 +1,52 @@
# Changelog
All notable changes to markdown-converter will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.1.0] - 2026-06-08
### Added
- Auto-update via electron-updater against GitHub Releases (default) or ConcreteInfo self-hosted feed.
- Light, skippable first-run wizard: theme, update channel, starter template.
- One-shot v4.4.1 → v5 settings migration with backup at `settings.v4.bak.json`.
- Local crash dump capture (cap 20, auto-prune) and a CrashReportModal.
- "Updates" section in Settings: channel picker, Check now, auto-check toggle.
## [5.0.0] - 2026-06-06
### Added
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI (Phases 1-9 of the React UI redesign)
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
- Settings sheet with 5 tabs (editor, theme, keybindings, advanced, about) and 17+ persisted fields
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
- 10 advanced tools: ASCII generator (figlet), table generator, Word export (.docx via `docx` lib), find-in-files (recursive regex), REPL (markdown snippet preview), print preview, zen mode (Esc exits), minimap, breadcrumbs-with-symbols, git status (porcelain parser)
- Sonner toast notifications at 4 wire points (file save, open file/folder, 4 export dialogs)
- 3 mount strategies: ModalLayer dialogs, App.tsx global overlays, editor/sidebar integrations
- `ipc.file.writeBuffer` for renderer-side binary file output (used by Word .docx export)
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
- 305 unit + integration tests (vitest + React Testing Library)
- Per-package @radix-ui primitives (checkbox, dialog, select, switch, tabs, radio-group, scroll-area, slider, collapsible, label, context-menu)
- shadcn/ui (new-york style) primitives, manually pasted (CLI broken on Node 24)
- Feature-first main process decomposition: `src/main/{files,menu,window,word-template,utils}/` + glue files
### Changed
- **BREAKING**: Renderer is now React-only. The legacy `src/renderer.js` (5319 lines) and all vanilla-JS UI scripts/styles are removed.
- Main process decomposed from a 4311-line `src/main.js` into feature-first modules under `src/main/`
- New entrypoint: `src/main/index.js` (was `src/main.js`)
- `src/index.html` (1667-line legacy orphan) removed; renderer served by `src/renderer/index.html` (Vite root)
- IPC contract: handlers throw on error, `safeCall` catches → returns `{ ok: false, error }`. `result.ok` is at top level, NOT nested in `result.data.ok`
- Settings store: `useSettingsStore` (zustand persist with zod validation), 17+ persisted fields
- Modal state: `useAppStore.modal: ModalState` discriminated union with 13 kinds; `openModal` uses TS conditional types to enforce prop requirements
### Removed
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines)
- 8 legacy stylesheets: `src/styles.css`, `src/styles-modern.css`, `src/styles-concreteinfo.css`, `src/styles-sidebar.css`, `src/styles-zen.css`, `src/styles-welcome.css`, `src/fonts.css`
- 5 legacy scripts: `src/command-palette.js`, `src/print-preview.js`, `src/welcome.js`, `src/zen-mode.js`, `src/wordTemplateExporter.js`
- 2 legacy HTMLs: `src/ascii-generator.html`, `src/table-generator.html`
- `src/main.js` (4311-line god file, replaced by `src/main/index.js`)
- `src/index.html` (1667-line legacy orphan at project root, replaced by `src/renderer/index.html`)
- 9 dead IPC channels from `src/preload.js`: `toggle-command-palette`, `print-preview`, `print-preview-styled`, `open-ascii-generator`, `open-table-generator`, `show-ascii-generator`, `show-ascii-generator-window`, `show-table-generator`, `show-table-generator-window`
[5.0.0]: https://github.com/amitwh/markdown-converter/releases/tag/v5.0.0
+145
View File
@@ -0,0 +1,145 @@
# CLAUDE.md — MarkdownConverter (react-electron)
> General code-quality, TypeScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
## Project Overview
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, plugin system, and auto-updater.
- **Version:** 5.0.1
- **License:** MIT
- **App ID:** `com.concreteinfo.markdownconverter`
## Branch Specifics
This is the **React rewrite branch** — the renderer has been rebuilt with React 19 + TypeScript + Tailwind CSS + shadcn/ui, replacing the vanilla JS renderer from master. The main process remains vanilla CommonJS JavaScript. A dual dev workflow runs Vite (renderer) and Electron (main) concurrently.
Key differences from master:
- Renderer: React 19 + TypeScript (TSX) instead of vanilla JS DOM manipulation
- Bundler: Vite for the renderer (`vite.renderer.config.ts`)
- UI: shadcn/ui (Radix primitives + Tailwind) instead of hand-rolled CSS
- State: Zustand 5 stores instead of global mutable state
- Security: `contextIsolation: true` + `nodeIntegration: false` (preload with channel whitelisting) instead of master's open renderer
- Build: two-stage (Vite build renderer, then electron-builder packages) instead of single electron-builder pass
- Testing: dual test runners — Vitest (renderer/React/TS) + Jest (main process/JS)
- Main process is now in `src/main/` (modular) instead of flat `src/main.js`
- Adds auto-updater via `electron-updater` with GitHub Releases + self-hosted feed support
- Adds React Hook Form + Zod for form validation, Lucide React icons, Motion for animations
- Legacy sidebar modules (`src/sidebar/*.js`) still exist alongside new React sidebar (`src/renderer/components/sidebar/`)
## Architecture
### Main Process (`src/main/`)
Modular structure (improvement over master's monolith):
- `src/main/index.js` — IPC handlers, Pandoc invocation, export logic (~3,500 lines)
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib`
- `src/main/GitOperations.js` — Git operations via `simple-git`
- `src/main/store.js` — Custom JSON settings store (NOT electron-store)
- `src/main/window/index.js` — BrowserWindow creation with three-mode loading (dev/prod/packaged)
- `src/main/menu/` — Application menu definitions
- `src/main/ipc/` — Crash handlers, updater handlers
- `src/main/updater/` — Auto-update service, feed config, migration runner
- `src/main/files/` — File operation modules
- `src/main/word-template/` — Word template export
### Preload (`src/preload.js`)
Properly isolated. Uses `contextBridge` with **channel whitelisting** (`ALLOWED_SEND_CHANNELS`, `ALLOWED_RECEIVE_CHANNELS` arrays). The renderer has no direct Node access.
### Renderer (`src/renderer/`)
React 19 + TypeScript application bundled by Vite:
- `src/renderer/App.tsx` — Root component: assembles AppShell, modals, command palette, toaster
- `src/renderer/components/layout/AppShell.tsx` — Three-panel resizable layout (sidebar | editor | preview)
- `src/renderer/stores/` — Zustand stores: `app-store`, `editor-store`, `file-store`, `preview-store`, `settings-store`, `command-store`
- `src/renderer/hooks/` — Custom hooks: `use-shortcut`, `use-file-shortcuts`, `use-menu-action`, `use-scroll-sync`, `use-zen-mode`, `use-export-source`
- `src/renderer/components/modals/` — 30+ modal dialogs as React components
- `src/renderer/components/editor/` — CodeMirror 6 editor React wrapper
- `src/renderer/components/preview/` — Markdown renderer with Mermaid lazy loading
- `src/renderer/components/sidebar/` — React sidebar panels (FileTree, GitStatus, Outline, Snippets, Templates)
- `src/renderer/components/ui/` — shadcn/ui primitives (button, dialog, sheet, select, tabs, etc.)
- `src/renderer/lib/` — Utilities: typed IPC wrapper (`ipc.ts`), export modules, validators
- `src/renderer/types/` — TypeScript declarations: `electron.d.ts` (window.electronAPI), `ipc.ts` (IPC types)
### Security Model
- **`contextIsolation: true` + `nodeIntegration: false`** (properly secured, unlike master)
- Preload with explicit channel whitelisting
- TypeScript declarations ensure type-safe IPC
- Pandoc invoked via `execFile` (not `exec`)
- Permission handler: only `clipboard-read`/`clipboard-write` allowed
- ESLint enforces `no-eval`, `no-implied-eval`, `no-new-func`
- CSP in `index.html` restricts script/style/img/font/connect sources
### Plugin System (`src/plugins/`)
Unchanged from master. Manifest-based discovery, built-in `writing-studio` plugin.
## System Dependencies
| Dependency | Required | Notes |
|---|---|---|
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x; Vite 8 requires Node 18+ |
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. |
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` |
| **MiKTeX / TeX Live** | Optional | LaTeX PDF export; MiKTeX PATH injected on Windows |
| **ImageMagick** | Optional | Linux image conversion; deb dependency |
| **LibreOffice** | Optional | Enhanced document conversion; deb dependency |
## Development Commands
```bash
npm run dev # Start dev mode: Vite dev server (port 5173) + Electron (concurrently)
npm run dev:renderer # Vite dev server only (port 5173)
npm run dev:electron # Electron only (waits for Vite on tcp:5173)
npm start # Launch Electron app (prod mode, requires built renderer)
npm run preview # Build renderer then launch Electron
npm test # Jest — main process tests (vanilla JS)
npm run test:renderer # Vitest — renderer tests (React/TS)
npm run lint # ESLint check
npm run lint:fix # ESLint auto-fix
npm run format # Prettier write
npm run format:check # Prettier check
npm run download-tools # Download Pandoc binaries
npm run generate-icons # Generate app icons
```
**Dev workflow:** `npm run dev` starts Vite (renderer HMR on `:5173`) and Electron concurrently. The main process loads from `http://localhost:5173` in dev mode.
## Build & Package
**Two-stage build process:**
1. `npm run build:renderer` — Vite builds renderer to `dist/renderer/`
2. `npm run build` — electron-builder packages main process + preload + built renderer
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json`.
| Target | Platforms |
|---|---|
| `npm run build:win` | Windows: NSIS + portable + zip (x64) |
| `npm run build:mac` | macOS: dmg + zip (x64 + arm64) |
| `npm run build:linux` | Linux: deb + AppImage + snap |
**Packaged files:** `src/main/**`, `src/preload.js`, `src/plugins/**`, `package.json`. Renderer built output copied to resources as `renderer/`.
**Bundled with builds:** Pandoc binary per platform, FFmpeg (asarUnpacked).
**Output:** `dist/` directory.
**Auto-updater:** `electron-updater` with GitHub Releases (default) and optional ConcreteInfo self-hosted feed.
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
## Project Conventions / Gotchas
- **Dual-process architecture.** Main process is vanilla CommonJS JavaScript (`src/main/`). Renderer is React 19 + TypeScript + Tailwind (`src/renderer/`). They are separate build targets.
- **Vite for renderer only.** Main process is NOT bundled — Electron loads `src/main/index.js` directly. Do not add TypeScript to main process files.
- **Tailwind + shadcn/ui.** Use shadcn/ui components (`src/renderer/components/ui/`) for all UI primitives. Custom theme in `tailwind.config.js`. Path alias `@` maps to `src/renderer/`. Brand color: `#e5461f`.
- **Zustand for state.** All renderer state lives in `src/renderer/stores/`. Each store is a separate file. Use immer for immutable updates.
- **Typed IPC.** `src/renderer/types/electron.d.ts` declares `window.electronAPI`. `src/renderer/lib/ipc.ts` provides type-safe wrappers. When adding new IPC channels, update both the preload whitelist AND the TypeScript declarations.
- **Legacy + new sidebar.** `src/sidebar/*.js` (vanilla JS) and `src/renderer/components/sidebar/` (React TSX) both exist. New sidebar features go in the React version.
- **Pandoc is external.** Must be present for non-HTML/PDF exports. Download via `npm run download-tools` or install system-wide.
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
- **PDF rendering:** `pdfjs-dist` (viewer). **PDF manipulation:** `pdf-lib` in main process.
- **Editor:** CodeMirror 6, configured in `src/editor/codemirror-setup.js`, wrapped as React component in `src/renderer/components/editor/CodeMirrorEditor.tsx`.
- **Tests:** Jest (main process, `tests/**/*.test.js`, 15% threshold) + Vitest (renderer, `tests/**/*.{test,spec}.{ts,tsx}`, v8 coverage on `src/renderer/`).
- **ESLint flat config** with ECMAScript 2022. Prettier: 2-space, single quotes, semicolons, 100-char width.
- **No tsconfig.json at root** — TypeScript is renderer-only, handled by Vite. Do not add `tsconfig.json` for the main process.
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
- **Single instance lock** via `app.requestSingleInstanceLock()`.
+15
View File
@@ -140,6 +140,21 @@ Open PDF files directly in MarkdownConverter:
- Rotate pages left or right - Rotate pages left or right
- Close PDF to return to editor - Close PDF to return to editor
## Distribution & Updates
v5.x uses `electron-updater` against two feeds:
- **GitHub Releases** (default, public): the public release at `https://github.com/amitwh/markdown-converter/releases`. The CI workflow publishes `latest-{mac,linux,windows}.yml` on every tag.
- **ConcreteInfo self-hosted** (opt-in for enterprise deployments): `https://updates.concreteinfo.co.in/v5/`. CI mirrors artifacts on every release when `CONCRETEINFO_DEPLOY_HOOK` is set as a repository secret.
Users switch the feed in **Settings → Updates → Update channel**. Auto-check is enabled by default; disable it in the same panel.
### Manual mirror
```bash
CONCRETEINFO_DEPLOY_HOOK=... npm run publish:concreteinfo -- 5.1.0
```
## Open Source ## Open Source
MarkdownConverter is 100% open-source. All dependencies are permissively licensed: MarkdownConverter is 100% open-source. All dependencies are permissively licensed:
BIN
View File
Binary file not shown.
@@ -0,0 +1,270 @@
# Packaging — All-Platform Build + GitHub Releases
**Goal:** Make `npm run dist:all` produce Linux + Windows + macOS artifacts, and have the existing `.github/workflows/release.yml` create a GitHub Release on tag push with those artifacts. No code signing in this iteration.
**Scope (user decisions 2026-06-06):**
- Target platforms: **all three** (Linux .deb/AppImage/snap, Windows NSIS/portable/zip, macOS dmg/zip)
- Distribution: **GitHub Releases** (no auto-update; static artifacts only)
- Code signing: **skip for now** (unsigned binaries; Windows SmartScreen will warn, macOS Gatekeeper will quarantine)
**Architecture:** This is mostly about completing the existing scaffold. `electron-builder` 26.0.12, `scripts/download-tools.js`, `scripts/generate-icons.js`, the `package.json` scripts, and `release.yml` are all already in place. We need to (1) close a real bug in the `files` config, (2) add the macOS job to the workflow, and (3) declare macOS targets in the package config.
**Tech stack:** electron-builder 26.0.12, GitHub Actions `ubuntu-latest`/`windows-latest`/`macos-latest` runners, softprops/action-gh-release@v2 for the release creation.
---
## Task 1 — Fix the `files` config in `package.json`
The current `files: ["src/**/*", ...]` matches `src/renderer/**/*.tsx` and ships dev source into the asar. The shipped renderer should be the built `dist/renderer/**/*` output, not TS source. asarUnpack for ffmpeg-static is correct and stays.
**Files:** `package.json` (build.files, build.extraResources)
### Step 1.1 — Replace `files` block
```json
"files": [
"src/main/**/*",
"src/preload.js",
"src/plugins/**/*",
"package.json"
],
"extraResources": [
{
"from": "dist/renderer",
"to": "renderer"
}
]
```
- `src/main/**/*` — main process source (entry point + all main-process modules)
- `src/preload.js` — single preload file at the root of `src/`
- `src/plugins/**/*` — built-in plugins (referenced from main process; the `extraFiles: []` line in the current config is empty, so this is the right pattern)
- `package.json` — required for the asar `app.asar/package.json` lookup electron does at runtime
- `dist/renderer` moves to `extraResources` because it should live alongside the asar (not inside it) — the asar is sealed and we want the renderer to be a separately-updatable resource path. Production main loads it via `loadFile(path.join(__dirname, '../../dist/renderer/index.html'))` which, in the packaged app, resolves to `process.resourcesPath/renderer/index.html`. **This requires changing the loadFile path in `src/main/window/index.js` too** — see Task 4.
### Step 1.2 — Verify
- `npm run build:linux` should produce a working .AppImage
- Inside the .AppImage (extract with `--appimage-extract`), check `resources/` has `app.asar` and a `renderer/` directory containing `index.html` and `assets/`
- Inside `app.asar`, check `src/main/index.js` is present and `src/renderer/` is absent
---
## Task 2 — Add macOS targets + icon to `package.json` build config
**Files:** `package.json`
### Step 2.1 — Replace the existing `mac` block
```json
"mac": {
"category": "public.app-category.productivity",
"identity": null,
"target": [
{ "target": "dmg", "arch": ["x64", "arm64"] },
{ "target": "zip", "arch": ["x64", "arm64"] }
],
"icon": "assets/icon.icns",
"darkModeSupport": true,
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": null
}
```
- `identity: null` is intentional for unsigned builds
- `darkModeSupport: true` — the app already supports dark mode; this is just metadata
- `hardenedRuntime: false` and `gatekeeperAssess: false` — without signing, these are the only way the build succeeds
- `entitlements: null` — no entitlements file; some features (jit) won't work but Electron's main process doesn't need them in this app
- **`.icns` is missing.** For this iteration we'll use the default Electron icon and add a follow-up to generate one. The build will succeed without it but the produced .app will have the default icon.
### Step 2.2 — Generate a placeholder `.icns` (optional, can be deferred)
If we want a proper icon, the path is:
1. `npm run generate-icons` to produce `assets/icons/*.png` (already done)
2. Use `png2icns` (Linux) or `iconutil` (mac) to bundle them into `assets/icon.icns`
3. We don't have either on this Linux container; defer to a follow-up plan
For this iteration: ship without `.icns` and accept the default icon.
### Step 2.3 — Add `publish` config
```json
"publish": {
"provider": "github",
"owner": "amitwh",
"repo": "markdown-converter",
"releaseType": "release"
}
```
Even without auto-update, this tells `electron-builder` to write `latest-mac.yml` / `latest-linux.yml` next to the artifacts. Useful for future auto-update wiring. CI passes `--publish=never` to override.
---
## Task 3 — Add macOS job to `.github/workflows/release.yml`
**Files:** `.github/workflows/release.yml`
### Step 3.1 — Add a third job between `build-windows` and `release`
Insert after the `build-windows` job (around line 96):
```yaml
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Download external tools (pandoc)
run: node scripts/download-tools.js
- name: Run tests
run: npm test
- name: Build macOS packages (unsigned)
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npm run build:mac -- --publish=never
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: macos-artifacts
path: |
dist/*.dmg
dist/*.zip
retention-days: 5
```
### Step 3.2 — Wire macOS into the `release` job's `needs` and downloads
Change the `release` job:
```yaml
release:
needs: [build-linux, build-windows, build-macos]
...
```
And add a third download step (after the Windows download):
```yaml
- name: Download macOS artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: macos-artifacts
path: dist
```
`continue-on-error: true` is intentional — a failed macOS build shouldn't block a release of working Linux+Windows artifacts.
---
## Task 4 — Update `src/main/window/index.js` for the resource path
**Files:** `src/main/window/index.js`
### Step 4.1 — Update the loadFile path
The current code (line 35):
```js
const prodPath = path.join(__dirname, '../../dist/renderer/index.html');
```
Inside the packaged app, `__dirname` points into the asar. `process.resourcesPath` points to the directory holding `app.asar` + the `renderer/` extraResources dir. Change to:
```js
const rendererIndex = app.isPackaged
? path.join(process.resourcesPath, 'renderer', 'index.html')
: path.join(__dirname, '../../dist/renderer/index.html');
win.loadFile(rendererIndex);
```
This needs to happen after the dev/prod branch is decided. The existing `if (!app.isPackaged && devServerUrl)` block already handles dev mode; the else branch needs this update.
### Step 4.2 — Add a guard for missing renderer in prod
```js
if (app.isPackaged) {
try {
fs.accessSync(path.join(process.resourcesPath, 'renderer', 'index.html'));
} catch {
console.error('[WINDOW] Renderer not found at', process.resourcesPath, '— did you run `npm run build:renderer` before packaging?');
}
}
```
Failure mode: someone runs `npm run build` without first building the renderer. Without this guard, the window opens blank and the user has no idea why.
---
## Task 5 — Test on this Linux box
### Step 5.1 — Local verification
```bash
npm ci
npm run download-tools # idempotent; bin/linux/pandoc already exists
npm run build:renderer
npm run build:linux # produces .deb, .AppImage in dist/
```
Inspect the .AppImage:
```bash
chmod +x dist/*.AppImage
./dist/MarkdownConverter-*.AppImage --appimage-extract
ls squashfs-root/resources/
# Expect: app.asar renderer/
ls squashfs-root/resources/renderer/
# Expect: index.html assets/
```
### Step 5.2 — Run the produced app
```bash
./dist/MarkdownConverter-*.AppImage
```
The app should:
- Launch with the AppShell, header, sidebar
- Allow opening a folder / file
- Export to PDF/DOCX/HTML work (pandoc bundled)
### Step 5.3 — Tag + push to test the release workflow (optional)
Only do this if the user wants to verify the full pipeline. A dry-run on the CI side via `act` would be cleaner but `act` doesn't run macos-latest jobs locally. If the user wants to verify the workflow, push a `v5.0.1-rc1` tag and watch the Actions tab.
---
## Success criteria
1. `npm run dist:all` produces all three platform families on this Linux box (or at least Linux + mac, since Windows is built on Windows)
2. The .AppImage launches and the app works end-to-end
3. `release.yml` runs all three jobs on a tag push and creates a GitHub release with the union of artifacts
4. No regressions: 306/306 tests still pass, dev workflow still works
5. The `files` bug is fixed: no `.tsx` in the packaged asar
## Known limitations (intentional, can be follow-ups)
- **No `.icns`** — the macOS app will have the default Electron icon. Generating a proper `.icns` requires `iconutil` (mac) or `png2icns` (cross-platform); neither is on this Linux box.
- **No code signing** — Windows SmartScreen will warn; macOS Gatekeeper will quarantine unsigned `.dmg`. Setting up signing later is a config-only change (no structural rework): add `CSC_LINK`/`CSC_KEY_PASSWORD` for Windows, `CSC_LINK` (Apple Developer ID) for mac.
- **No auto-update** — user decisions for this iteration. Wiring `electron-updater` is a follow-up; the `publish` config from Task 2 puts metadata in the right place.
- **Cross-build quirks** — building macOS from Linux produces a `.dmg` that is generally usable but may show a "this app is from an unidentified developer" prompt the first time. Right-click → Open to bypass once. Documented in the release notes.
## Out of scope
- CHANGELOG.md updates (not part of packaging)
- electron-updater wiring
- Code signing cert procurement
- Snap store / Microsoft Store / Mac App Store submission
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,333 @@
# Phase 10 — Polish + Delete Legacy Design
> Companion to the parent plan: `docs/superpowers/plans/2026-06-05-react-ui-redesign.md` (Phase 10 is sketched at high level; this spec locks architecture, file map, deletion targets, and version strategy.)
**Date:** 2026-06-06
**Phase:** 10 of 10 (React + shadcn/ui UI redesign) — **FINAL**
**Tag (on completion):** `v5.0.0` (the first release tag, all prior phases were `phase-N-*` working tags)
---
## 1. Goal & Non-Goals
**Goal:** Finalize the React UI redesign. Decompose the legacy 146KB `src/main.js` into a feature-first modular structure under `src/main/`, remove the legacy vanilla-JS renderer and all dead IPC bridges, then ship **v5.0.0** with a CHANGELOG.
**Non-goals (Phase 10):**
- Adding new features (Phases 1-9 shipped all of them)
- A full main-process test suite (the existing main process is largely untested; adding tests is Phase 11+)
- Backwards compat shims for the legacy renderer
- Renderer-side refactor (already done)
- Migrating the renderer build pipeline further (vite configs are in place)
**Success criteria:**
- `src/main.js` is gone; `src/main/index.js` is the new entrypoint
- `package.json#main` points to `src/main/index.js`
- All 12 legacy renderer files are deleted
- All 9 dead IPC channels are removed from `preload.js` and `main.js`
- `src/index.html` is reduced to ~50 lines (just the Vite bootstrap)
- `package.json#version` is `5.0.0`
- `CHANGELOG.md` exists in Keep a Changelog 1.1.0 format
- `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns zero results
- All 305 tests still pass
- `npx vite build --config vite.renderer.config.ts` succeeds
- `npx electron .` launches and main window renders
- Tag `v5.0.0` pushed to origin
---
## 2. Target Architecture
### 2.1 `src/main/` (feature-first decomposition)
```
src/main/
├── index.js # entrypoint: bootstraps store, app, window, ipc
├── store.js # electron-store wrapper (preferences + wordTemplatePath)
├── ipc.js # ipcMain.handle registration (composes from modules)
├── files/
│ ├── index.js # file ops facade (read/write/list/pickFolder/pickFile)
│ ├── search.js # recursive regex search (used by find-in-files)
│ ├── git.js # git status porcelain parser
│ └── binary.js # writeBuffer helper (Uint8Array → file)
├── menu/
│ ├── index.js # buildMenu() — composes the app menu
│ └── items.js # individual menu items (File, Edit, View, etc.)
├── window/
│ ├── index.js # createMainWindow ONLY (createAsciiWindow/createTableWindow are DEAD)
│ └── state.js # window state persistence
├── word-template/
│ ├── index.js # WordTemplateExporter facade
│ ├── parser.js # .dotx parsing
│ ├── converter.js # markdown → docx
│ └── apply.js # apply template styles to converted docx
└── utils/
├── paths.js # path helpers
├── logger.js # structured logging
└── download.js # tool downloader
```
**Module rules:**
- Each file has one clear responsibility (no god files; CLAUDE.md says >300 lines is the cap)
- `index.js` is the public face of a folder; deeper files are private
- `src/main/index.js` (top-level) wires everything together
- Cross-folder imports go through `index.js`, not directly to internals
### 2.2 Entry point change
**Before:**
```json
// package.json
"main": "src/main.js"
```
**After:**
```json
// package.json
"main": "src/main/index.js"
```
The new `src/main/index.js` runs the same `app.whenReady().then(...)` flow that the current `src/main.js` does, but composes the decomposed modules.
### 2.3 What becomes dead code (removed during decomposition)
When the legacy renderer files are deleted, these main-process functions become orphaned and are removed too:
| Function | File | Why dead |
|---|---|---|
| `createAsciiWindow()` | `src/main/window/index.js` | Loads deleted `src/ascii-generator.html` |
| `createTableWindow()` | `src/main/window/index.js` | Loads deleted `src/table-generator.html` |
| `ipcMain.on('open-ascii-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<AsciiGeneratorDialog>` |
| `ipcMain.on('open-table-generator', ...)` | `src/main/index.js` (after decomposition) | Replaced by React `<TableGeneratorDialog>` |
| `webContents.send('print-preview')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
| `webContents.send('print-preview-styled')` | `src/main/menu/items.js` | Replaced by React `<PrintPreview>` |
| `webContents.send('toggle-command-palette')` | `src/main/menu/items.js` | Replaced by `useCommandStore` |
| `require('./wordTemplateExporter')` | `src/main/index.js` (after decomposition) | Replaced by `src/main/word-template/` + renderer-side `lib/docx-export.ts` |
---
## 3. Legacy Deletion Map
### 3.1 Files to delete from `src/` (12 files, ~14,426 lines)
| File | Size | Lines | Replaced by |
|---|---|---|---|
| `src/renderer.js` | 213KB | 5319 | `src/renderer/` (Phases 1-9) |
| `src/styles.css` | 74KB | 3723 | `src/renderer/index.css` + Tailwind |
| `src/styles-modern.css` | 71KB | 2625 | Tailwind + shadcn/ui |
| `src/styles-concreteinfo.css` | 21KB | 969 | Tailwind theme tokens |
| `src/styles-sidebar.css` | 9.7KB | 304 | `<Sidebar>` component |
| `src/styles-zen.css` | 2.1KB | 102 | Zen mode in AppShell |
| `src/styles-welcome.css` | 2.2KB | 24 | Welcome dialog |
| `src/fonts.css` | 1.8KB | (imported elsewhere) | Tailwind font config |
| `src/command-palette.js` | (small) | 109 | `useCommandStore` |
| `src/print-preview.js` | (small) | 138 | `<PrintPreview>` |
| `src/welcome.js` | (small) | 78 | `<Welcome>` modal |
| `src/zen-mode.js` | (small) | 292 | `use-zen-mode` hook + AppShell |
| `src/wordTemplateExporter.js` | (small) | 743 | `src/main/word-template/` + renderer `lib/docx-export.ts` |
| `src/ascii-generator.html` | 34KB | (HTML) | `<AsciiGeneratorDialog>` |
| `src/table-generator.html` | 18KB | (HTML) | `<TableGeneratorDialog>` |
| `src/index.html` | 103KB | 1667 | `src/renderer/index.html` (already exists, Vite root) |
**Total: 13 files = ~16,093 lines / ~548KB**
`src/renderer/index.html` (the live Vite template) is NOT deleted.
### 3.2 Dead IPC channels to remove from `src/preload.js`
- `toggle-command-palette` (line 238)
- `open-ascii-generator` (line 89)
- `open-table-generator` (line 92)
- `print-preview` (line 173)
- `print-preview-styled` (line 174)
- `show-table-generator` (line 180)
- `show-ascii-generator-window` (line 217)
- `show-ascii-generator` (line 218)
- `show-table-generator-window` (line 221)
And from the exposed API surface:
- `openAscii: () => ipcRenderer.send('open-ascii-generator')` (line 445)
- `openTable: () => ipcRenderer.send('open-table-generator')` (line 446)
**9 channel names + 2 API entries to remove.**
### 3.3 Legacy references in `src/index.html` (16 places) — **the LEGACY file at project root, 1667 lines**
- `<link rel="stylesheet" href="styles.css">` (line ~6)
- `<link rel="stylesheet" href="styles-welcome.css">` (line ~7)
- `<div id="print-preview-overlay" class="modal hidden" ...>` block (~10 lines)
- `<div class="command-palette-overlay hidden" id="command-palette-overlay">` block (~5 lines)
- `<script src="renderer.js"></script>` (final script tag)
**All of these are removed by deleting the file `src/index.html` entirely.**
The CURRENT live renderer template is **`src/renderer/index.html`** (already correct: minimal, CSP, Plus Jakarta Sans font, `<script type="module" src="./main.tsx">`). This file is **NOT touched** in Phase 10. Vite's `root` is set to `src/renderer/` in `vite.renderer.config.ts`, so `src/renderer/index.html` is the HTML template Vite uses. The `src/index.html` at the project root is an orphan from the pre-Vite era.
**Verification:** after deleting `src/index.html`, the renderer build still works because Vite doesn't look at the project root — it uses `src/renderer/index.html`.
### 3.4 Main process rewiring
- Remove `require('./wordTemplateExporter')` from main entrypoint
- Remove `webContents.send('print-preview*')` and `webContents.send('toggle-command-palette')` from menu items
- Remove `ipcMain.on('open-ascii-generator', ...)` and `ipcMain.on('open-table-generator', ...)` handlers
- Remove `asciiGeneratorWindow` and `tableGeneratorWindow` global state (and any references to `loadFile(...ascii-generator.html)` / `loadFile(...table-generator.html)`)
---
## 4. `src/index.html` — DELETE (not rewrite)
The current `src/index.html` is 1667 lines of legacy markup and is **DELETED** (not rewritten). The renderer is already correctly served by `src/renderer/index.html` (the Vite root). Deleting the legacy root-level `src/index.html` is the action — no replacement file is needed.
`src/renderer/index.html` (already correct) is NOT modified:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; ...">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MarkdownConverter</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:..." rel="stylesheet">
</head>
<body class="font-sans antialiased">
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
```
---
## 5. Version Bump + CHANGELOG
### 5.1 `package.json`
```diff
- "version": "4.4.2",
+ "version": "5.0.0",
```
Major version bump because the legacy renderer removal is a breaking change for anyone who had plugins/integrations pointing at `src/renderer.js`.
### 5.2 `CHANGELOG.md` (new file, Keep a Changelog 1.1.0 format)
```markdown
# Changelog
All notable changes to markdown-converter will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.0.0] - 2026-06-06
### Added
- **Complete React 19 + Vite + TypeScript renderer** replacing the legacy vanilla-JS UI
- Native macOS/Windows/Linux menus with command palette and keyboard shortcuts
- Settings sheet (5 tabs: editor, theme, keybindings, advanced, about)
- Modal layer with 13 modal kinds (export PDF/DOCX/HTML/Word, batch, settings, about, welcome, confirm, ASCII gen, table gen, find in files)
- 10 advanced tools: ASCII generator, table generator, Word export, find-in-files, REPL, print preview, zen mode, minimap, breadcrumbs-with-symbols, git status
- Sonner toast notifications at 4 wire points
- 3 mount strategies: ModalLayer dialogs, App.tsx overlays, editor/sidebar integrations
- `ipc.file.writeBuffer` for renderer-side binary output
- `ipc.file.search` (recursive regex), `ipc.file.gitStatus`, `ipc.print.show`, `ipc.app.showSaveDialog`
- 305 unit + integration tests (vitest + React Testing Library)
- Per-package @radix-ui primitives
- shadcn/ui (new-york style) primitives
### Changed
- **BREAKING**: Renderer is now React-only.
- Main process decomposed from 146KB `src/main.js` into feature-first modules under `src/main/`
- `src/index.html` reduced from 1667 to ~50 lines
- IPC contract: handlers throw, `safeCall` catches → `{ ok, error }`
- Settings store: `useSettingsStore` (zustand persist with zod)
- Modal state: `useAppStore.modal: ModalState` discriminated union
### Removed
- `src/renderer.js` (legacy vanilla-JS renderer, 5319 lines) — and `src/index.html` (1667 lines of legacy markup, replaced by `src/renderer/index.html` which is already the live Vite template)
- 7 legacy stylesheets (styles.css, styles-modern.css, styles-concreteinfo.css, styles-sidebar.css, styles-zen.css, styles-welcome.css, fonts.css)
- 5 legacy scripts (command-palette.js, print-preview.js, welcome.js, zen-mode.js, wordTemplateExporter.js)
- 2 legacy HTMLs (ascii-generator.html, table-generator.html)
- 9 dead IPC channels (toggle-command-palette, open-*/show-* ascii/table, print-preview*)
```
---
## 6. Task Decomposition
**10 tasks** (each independently verifiable, one feature per commit, no bundling):
1. **Decompose main.js — `src/main/files/`** (file ops facade, search, git, binary)
2. **Decompose main.js — `src/main/menu/`** (buildMenu + items, with dead sends removed)
3. **Decompose main.js — `src/main/window/`** (createMainWindow ONLY — ascii/table windows removed)
4. **Decompose main.js — `src/main/word-template/`** (WordTemplateExporter split into parser/converter/apply)
5. **Decompose main.js — `src/main/utils/` + `store.js` + `ipc.js` + `index.js`** (glue + new entrypoint)
6. **Trim preload.js** — remove 9 dead IPC channels + 2 exposed API entries
7. **Delete legacy renderer files** (13 files: renderer.js, 7 styles, fonts.css, 4 scripts, 3 htmls)
8. **Verify src/renderer/index.html is the live template** (no action needed if it's already correct)
9. **Bump package.json version to 5.0.0 + write CHANGELOG.md**
10. **Final verification + tag v5.0.0** (build, tests, grep, electron smoke)
Tasks 1-5 are subagent-driven (decomposition is mechanical given a clear target structure). Tasks 6-9 are direct edits. Task 10 is a verification gate.
---
## 7. Verification Strategy
**Per task:**
- After each decomposition step: `npx vitest run` (305 still green)
- After main.js decomposition: `npx electron .` smoke — app starts, opens a file
- After deletions: `git grep -E "renderer\.js|command-palette|print-preview|welcome\.js|zen-mode|wordTemplate|ascii-generator|table-generator|styles\.css"` returns ZERO results
**Final (Task 10):**
- `npx vitest run` — 305 passing
- `npx vite build --config vite.renderer.config.ts` — succeeds
- `npx vite build --config vite.preload.config.ts` — succeeds
- `npx electron .` — app launches, main window renders
- Manual smoke: open a `.md` file, edit it, export to PDF/DOCX, use the command palette
- `git tag -a v5.0.0 -m "..."` and `git push origin v5.0.0`
---
## 8. Risks & Open Questions
**Risks:**
- **Decomposition regressions.** Splitting a 146KB main.js into 7+ files has surface area for missed imports / circular deps. Mitigation: tests stay green; electron smoke after each step.
- **Hidden legacy references.** `git grep` won't catch references inside minified bundles or in node_modules. Mitigation: vite build will fail if there's a dangling import.
- **`src/main/index.js` entrypoint change.** Anything in the build pipeline (electron-builder, scripts) that hardcodes `src/main.js` needs to be updated. Mitigation: grep all of `package.json`, `scripts/`, `vite.*.config.ts` for `main.js`.
**Open questions (resolved during brainstorming):**
- ~~Main process scope~~ → Full rewrite
- ~~Folder shape~~ → Feature-first
- ~~IPC migration~~ → Delete dead channels
- ~~Version strategy~~ → v5.0.0 with Keep a Changelog format
- ~~Order of work~~ → Decompose first, delete last
---
## 9. Out of Scope (deferred to Phase 11+)
- Main process test suite
- Renderer-side further refactor
- New features
- Migration to tauri (separate plan in `docs/plans/2026-03-15-react-tauri-pwa.md`)
---
## 10. Success Criteria
Phase 10 is complete when:
- All 10 tasks are committed, one per commit, no bundling
- `src/main/index.js` is the new entrypoint
- All 12 legacy files are deleted
- All 9 dead IPC channels are removed
- `package.json#version` is `5.0.0`, `package.json#main` is `src/main/index.js`
- `CHANGELOG.md` exists with a complete v5.0.0 entry
- 305 tests still pass
- `npx vite build` succeeds for both renderer and preload configs
- `npx electron .` launches and the app works end-to-end
- `git grep` for legacy references returns zero results
- Tag `v5.0.0` is created and pushed to origin
- Phase 10 is the LAST phase of the React UI redesign
@@ -0,0 +1,295 @@
# Production polish — v5 shippable build
**Date:** 2026-06-07
**Branch:** `react-electron`
**Status:** Draft, pending implementation plan
**Author:** Claude (brainstormed with Amit Haridas)
## 1. Goals & non-goals
### Goals
v5 is reliably shippable to two distribution channels:
- **GitHub Releases** (public, free, existing workflow).
- **ConcreteInfo update server** at `https://updates.concreteinfo.co.in/v5/` (self-hosted, for ConcreteInfo's own users).
Concretely:
- A non-blocking update banner surfaces "v5.0.2 is available" with a user-confirmed restart-to-install flow. No silent background installs.
- A light first-run wizard (theme + update channel + starter template), all skippable.
- One-shot auto-migration from v4.4.1 settings, with a v4 backup file and a toast on success/failure.
- Local crash dump capture (no third-party). CrashReportModal lets the user open the dump folder and copy/delete dumps.
- GitHub Actions CI that builds + uploads artifacts on tag and runs the test suite on every PR.
### Non-goals (deferred to v5.1+)
- **Code signing.** macOS Gatekeeper and Windows SmartScreen will warn. Re-evaluated for v5.1.
- **Sentry or any third-party crash reporting.** Local dumps only.
- **Auto-install on quit.** Always user-confirmed.
- **Delta updates** (full downloads only in v5).
- **Plugin system, cloud/licensing, perf/a11y** — each is its own subsequent spec.
## 2. Architecture
```
GitHub Releases (public) ConcreteInfo (CI feed)
└── *.zip, *.dmg, *.exe, └── *.zip, *.dmg, *.exe,
*.deb, *.rpm, *.deb, *.rpm,
latest.yml, latest- latest.yml, latest-
mac.yml, latest- mac.yml, latest-
linux.yml, latest- linux.yml, latest-
windows.yml windows.yml
▲ ▲
│ │
└──────┬───────────────────────┘
│ electron-updater reads
│ the channel chosen in
│ Settings (default: GitHub)
┌─────────────────────────────────────────────────────┐
│ Main process (src/main/updater/) │
│ - Updater service: wraps electron-updater │
│ - Channel resolver: returns feed URL from setting │
│ - State machine: idle → checking → available → │
│ downloading → ready → installing │
│ - Crash writer: catches process.on('uncaught…) │
│ - Migration runner: idempotent, runs on app start│
└─────────────────────────────────────────────────────┘
▲ ▲
│ IPC (renderer→main, │ IPC (renderer→main,
│ allowlisted SEND) │ allowlisted SEND)
│ updater:check, │ crash:read,
│ updater:install, │ crash:open-dir,
│ updater:get-state │ crash:delete
│ │
│ IPC (main→renderer, │
│ allowlisted RECEIVE) │
│ updater:status │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ Renderer (preload bridge + React) │
│ - Update banner: "v5.0.2 available" │
│ - Settings: Update channel radio │
│ - First-run wizard: skip-links, all optional │
│ - Crash dialog: open dump dir, copy file │
│ - Migration: first-launch indicator in settings │
└─────────────────────────────────────────────────────┘
```
### Key boundaries
- **`src/main/updater/`** is a single new directory. `updater-service.js` owns the `electron-updater` lifecycle, never the renderer.
- **Renderer talks to updater over IPC only.** No direct `autoUpdater` import in preload. Channels must be in `ALLOWED_SEND_CHANNELS` / `ALLOWED_RECEIVE_CHANNELS`.
- **Channel switching is a settings change.** No restart required; the next `checkForUpdates()` reads the new feed URL.
- **Migration is idempotent.** Stores a `migration.version` key; skips if already at v5.
- **Crash writer never blocks startup.** Initialized in main entry, but a write failure is non-fatal.
## 3. Component breakdown
| Unit | What it does | How it's used | Depends on |
|---|---|---|---|
| `main/updater/updater-service.js` | Owns `electron-updater`; exposes check/download/install; emits status events | Main process singleton; started after `app.whenReady()` | `electron-updater`, `electron` |
| `main/updater/feed-config.js` | Maps channel setting → feed URL; reads from `app.getPath('userData')/settings.json` | Called by updater-service on each check | settings store |
| `main/updater/crash-writer.js` | Hooks `process.on('uncaughtException', …)`; writes minidump + stack to `app.getPath('userData')/crashDumps/{timestamp}.json` | Initialized in main entry, before anything else | `electron`, `app.getPath` |
| `main/updater/migration-runner.js` | Reads v4 settings; transforms to v5 schema; writes backup; updates `migration.version` | Run from main entry on `app.whenReady()` | `fs`, settings store |
| `main/ipc/updater-handlers.js` | IPC handlers: `updater:check`, `updater:install`, `updater:get-state` | Registered in main entry | updater-service |
| `main/ipc/crash-handlers.js` | IPC handlers: `crash:read`, `crash:open-dir` | Registered in main entry | crash-writer |
| `preload.js` (extension) | Add `updater.check()`, `updater.install()`, `crash.read()`, `crash.openDir()` to `electronAPI`; add channels to allowlist | Preload runs at boot | existing preload |
| `renderer/lib/updater-store.ts` | Zustand store mirroring updater state; subscribes to `updater:status` events | Renderer; consumed by banner, settings, modals | `zustand`, IPC bridge |
| `renderer/components/UpdateBanner.tsx` | Non-blocking banner shown when `updater-store.status` is `available | downloading | ready` | Mounted in `App.tsx` next to header | updater-store, sonner |
| `renderer/components/FirstRunWizard.tsx` | 3-step modal with skip links; opens on first launch when `app-store.firstRun === true` | Mounted in `App.tsx` | app-store, settings-store, command-store |
| `renderer/components/modals/SettingsModal.tsx` (extend) | Add "Updates" section: channel radio, "Check now" button, auto-check toggle | Mounted via `useAppStore.modal` | updater-store, settings-store |
| `renderer/components/modals/CrashReportModal.tsx` | List local crash dumps; "Open dump folder", "Copy to clipboard", "Delete" actions | Mounted via `useAppStore.modal` | crash ipc, sonner |
| `renderer/lib/migrations/v4-to-v5.ts` | Pure function: `(v4Settings) => v5Settings` | Called from `migration-runner` | zod schemas |
### Data flow — "user clicks Check for updates"
1. Settings panel: `useSettingsStore.getState().setSetting('updateChannel', 'github' | 'concreteinfo')`.
2. `SettingsModal` calls `await ipc.updater.check()`.
3. Preload `safeCall``window.electronAPI.updater.check()``ipcRenderer.invoke('updater:check')`.
4. Main handler asks `updater-service` to `autoUpdater.checkForUpdates()` with the channel's feed URL.
5. `updater-service` emits `'updater:status', { state: 'available', version: '5.0.2' }`.
6. Preload forwards to `updater-store` via `window.electronAPI.on('updater:status', cb)`.
7. `updater-store` state flips to `available`; `UpdateBanner` renders.
8. User clicks "View release notes" → opens `https://github.com/.../releases/tag/v5.0.2` via `ipc.app.openExternal`.
9. User clicks "Restart to update" → calls `ipc.updater.install()`. `updater-service` calls `autoUpdater.quitAndInstall()`. App quits, relaunches into v5.0.2.
## 4. Error handling
### Updater
- **No network:** `autoUpdater.checkForUpdates()` rejects with `ENOTFOUND` / `ETIMEDOUT`. We catch, log to main log, emit `updater:status { state: 'error', code: 'NETWORK' }`. Banner shows "Couldn't check for updates. Try again." Toast fires inline at the wire point in `UpdateBanner` / `SettingsModal` on retry, matching the existing pattern (`toasts-inline-at-wire-points`).
- **Feed 404 / signature missing (N/A in v5 since unsigned):** Treat as configuration error. Log to main log with full URL. Banner shows "Update feed is misconfigured. Report this on GitHub." Crash-log-style entry is written.
- **Update available but download fails:** Banner flips to "Download failed. Try again." Clicking "Try again" re-invokes `autoUpdater.downloadUpdate()`. Three consecutive failures show a "Report issue" link in the banner.
- **Update installs but launch fails:** Worst case. We catch the post-`quitAndInstall` crash via `crash-writer` and write a marker file `app.getPath('userData')/update-failed.json` with the previous version. On next boot, the user is offered to revert manually by re-downloading from the GitHub release page (a "rolled back" banner instead of the wizard).
- **Concurrent checks:** Debounce 60s. A second `checkForUpdates()` within 60s is a no-op; returns the current state. The Check-Now button is disabled during a check.
### Migration
- **v4 settings file missing/corrupt:** Treat as "nothing to migrate." App starts with v5 defaults. Toast: "Welcome to v5 — using default settings."
- **Migration throws mid-transform:** Original v4 file is preserved as `settings.v4.bak.json` (already on disk). App starts with v5 defaults. Toast: "Couldn't migrate v4 settings — your old settings are preserved at {path}."
- **`migration.version === 5`:** Skip entirely. No-op.
### Crash writer
- **Dump write fails (disk full, permissions):** Print to stderr; do not crash the app. Recovery is "user's problem" — we never block app startup on the crash writer.
- **No dumps in dir:** CrashReportModal shows empty state: "No crashes recorded — nice work!"
### First-run wizard
- **Skip clicked at any step:** Sets `app-store.firstRun = false` and proceeds. All defaults already match the unselected choice, so this is non-destructive.
- **Wizard cannot reach settings (corrupt store):** Falls back to defaults silently. Wizard still closes.
## 5. Testing
### Unit (Vitest, renderer-side)
- `useUpdaterStore`: state transitions on each `updater:status` event; debounce on `check()`; reject on missing channel.
- `FirstRunWizard`: each step's CTA writes the right slice; "Skip" leaves defaults; "Skip at step 1" still closes the wizard and sets `firstRun = false`.
- `migrations/v4-to-v5`: golden-file tests — every v4 shape we ship transforms to expected v5 output. Idempotency: running twice yields identical v5 settings.
- `feed-config`: each channel setting resolves to the right feed URL; missing/unknown channel falls back to GitHub.
### Component (Vitest + RTL)
- `UpdateBanner`: hidden when state is `idle` or `error-network`; visible with the right copy in `available`/`downloading`/`ready`; Restart button calls `ipc.updater.install()`; "View release notes" opens the right URL via `ipc.app.openExternal`.
- `FirstRunWizard`: skip links work; theme picker updates the store; channel picker persists; template picker inserts into the new buffer.
- `CrashReportModal`: lists dumps from the IPC response; "Open folder" invokes `crash.openDir`; delete removes the file.
- `SettingsModal > Updates`: channel radio flips `settings-store.updateChannel`; "Check now" fires `ipc.updater.check`; auto-check toggle persists.
### Integration (Vitest, full app)
- **Migration end-to-end:** pre-seed a v4 settings file in `app.getPath('userData')/settings.json`, launch the app, verify v5 file is written, v4 backup exists, `migration.version === 5`, and the wizard opens on first run.
- **Update flow mock:** stub `electron-updater` to emit `available``downloading``ready`; verify the banner appears, Restart click triggers `quitAndInstall`, and the IPC sequence matches the allowlist.
### E2E (Playwright + Electron, runs the live app)
- Open the app, verify the FirstRun wizard renders. Skip. Verify the editor is visible.
- Open Settings, switch update channel from GitHub to ConcreteInfo, click Check now. Stub the feed response. Verify the banner appears with the right version.
- Click Restart. Verify the app calls `quitAndInstall` (assert on a mocked `autoUpdater.quitAndInstall`).
- Trigger an unhandled rejection in the renderer. Verify a dump is written to `crashDumps/`. Open the CrashReportModal, verify it appears in the list.
### Coverage targets
- Updater service: ≥ 90% (small surface, hot path).
- Migration runner: 100% of branches (idempotency, corrupt file, missing file, transform failure).
- FirstRunWizard: ≥ 80%.
- Component tests above any preset.
## 6. Distribution feed contracts
### GitHub Releases (default)
Feed URL pattern:
```
https://github.com/{owner}/{repo}/releases/download/v{version}/
```
For v5.0.2:
```
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-mac.yml
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-linux.yml
https://github.com/{owner}/{repo}/releases/download/v5.0.2/latest-windows.yml
```
`electron-updater` reads these automatically given the GitHub owner/repo config. The release workflow already exists at `.github/workflows/release.yml` and builds `.dmg`, `.zip`, `.exe`, `.deb`, `.rpm`. We add `latest.yml` generation via `electron-builder --publish always` in the CI.
### ConcreteInfo feed
Feed URL:
```
https://updates.concreteinfo.co.in/v5/latest.yml
https://updates.concreteinfo.co.in/v5/latest-mac.yml
https://updates.concreteinfo.co.in/v5/latest-linux.yml
https://updates.concreteinfo.co.in/v5/latest-windows.yml
```
CI mirrors the GitHub Release artifacts to this path on every release. ConcreteInfo infra is documented in `~/.claude-mmax/CLAUDE.md`; the coolify server is `localhost:8000`. The mirror is a static-file route — no auth, just version-prefixed.
## 7. First-run wizard contract
Three steps. Each step has "Skip" and "Back" links. Final step is "Done."
1. **Theme:** Light / Dark / System. Default: System. Persists to `settings-store.theme`.
2. **Update channel:** GitHub Releases / ConcreteInfo. Default: GitHub Releases. Persists to `settings-store.updateChannel`.
3. **Starter template:** Blank / README / Meeting notes / Blog post. Default: Blank. On "Done," creates an untitled buffer with the chosen template content.
`firstRun` lives in `app-store` (not persisted across app data resets) and is checked on every launch. Setting it to `false` either explicitly (skip/done) or implicitly (v4 migration succeeded) prevents re-showing.
## 8. Settings migration contract (v4.4.1 → v5.0.0)
`migration-runner` runs once on first v5 launch. It:
1. Reads `app.getPath('userData')/settings.json` if it exists.
2. Validates the v4 shape with a zod schema (`v4SettingsSchema`).
3. If valid: transforms via `migrations/v4-to-v5.ts` and writes the v5 settings to `settings.json`. Backs up v4 to `settings.v4.bak.json`. Sets `migration.version = 5` in the v5 file.
4. If missing or invalid: writes a fresh v5 settings file with defaults. `migration.version = 5`.
5. If transform throws: leaves v4 file in place as the "backup." App starts with defaults. Toast warns the user.
The transform handles:
- v4 `theme` ('light' | 'dark' | 'auto') → v5 `theme` ('light' | 'dark' | 'system').
- v4 `customCss` string → v5 `customCssPath` file path or `null`.
- v4 `recentFiles: string[]` → v5 `recentFiles` (unchanged shape).
- v4 `editorFontSize: number` → v5 `editorFontSize` (unchanged shape).
- v4 `keyBindings: object` → v5 `userBindings` (keymap).
- v4 `snippets: array` → v5 `snippets` (unchanged shape).
- v4 `pdfExportOptions` etc. → v5 schema for the new export pipeline.
Anything not in the v4 schema is dropped. Defaults are taken from the v5 zod schema.
## 9. IPC allowlist additions
`src/preload.js` adds these to `ALLOWED_SEND_CHANNELS`:
```js
'updater:check',
'updater:install',
'updater:get-state',
'crash:read',
'crash:open-dir',
'crash:delete',
```
`ALLOWED_RECEIVE_CHANNELS`:
```js
'updater:status',
```
The `electronAPI` object gets:
```js
updater: {
check: () => ipcRenderer.invoke('updater:check'),
install: () => ipcRenderer.invoke('updater:install'),
getState: () => ipcRenderer.invoke('updater:get-state'),
onStatus: (cb) => ipcRenderer.on('updater:status', (_, payload) => cb(payload)),
},
crash: {
read: () => ipcRenderer.invoke('crash:read'),
openDir: () => ipcRenderer.send('crash:open-dir'),
delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
},
```
## 10. Risks & mitigations
| Risk | Mitigation |
|---|---|
| First-run wizard is annoying to long-time users | Skippable; only shows on first launch. `firstRun = false` is set permanently on first skip/done. |
| Auto-migration corrupts settings | Original v4 file is preserved as `settings.v4.bak.json`. Transform errors fall back to defaults and toast the user. |
| Crash writer fills disk | Cap at 20 dumps; oldest auto-pruned. |
| Update feed mirror drift between GitHub and CI | Mirror runs in CI on every release; manual `npm run publish:concreteinfo` available as fallback. |
| `electron-updater` is unsigned, so anyone can publish a "v5.0.2" to a mirror | We do not enable auto-install in v5 — every install is user-confirmed via the release-notes page. v5.1 adds signing. |
| First-run wizard blocks app start on a slow renderer | Wizard is non-blocking. App shell mounts and is interactive behind the modal. |
| Debounced `checkForUpdates()` may mask real errors | "Check now" button bypasses the debounce; debounce only applies to automatic checks. |
| CI release workflow is on a tag — broken tags must be re-tagged manually | Documented in CONTRIBUTING.md; pre-release tag re-runs the workflow. |
## 11. Out of scope (deferred)
- Code signing.
- Delta updates.
- Per-channel pre-release / beta tracks (only stable feeds in v5).
- Crash analytics / Sentry.
- Squirrel.Windows-specific quirks (we publish NSIS only).
+1 -1
View File
@@ -85,7 +85,7 @@ module.exports = [
}, },
rules: { rules: {
// Error prevention // Error prevention
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-undef': 'error', 'no-undef': 'error',
'no-console': 'off', // Allow console for Electron apps 'no-console': 'off', // Allow console for Electron apps
+1 -2
View File
@@ -19,8 +19,7 @@ module.exports = {
// Coverage configuration // Coverage configuration
collectCoverageFrom: [ collectCoverageFrom: [
'src/**/*.js', 'src/**/*.js',
'!src/main.js', // Main process needs electron-mock '!src/main/**', // Main process needs electron-mock
'!src/renderer.js', // Large renderer file with duplicate declarations
'!src/preload.js', // Electron preload requires contextBridge '!src/preload.js', // Electron preload requires contextBridge
'!**/node_modules/**' '!**/node_modules/**'
], ],
+490 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.4.2", "version": "5.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.4.2", "version": "5.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
@@ -46,6 +46,7 @@
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"electron-updater": "^6.8.9",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0", "figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -86,6 +87,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^10.0.3",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@@ -104,7 +106,8 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.8" "vitest": "^4.1.8",
"wait-on": "^9.0.10"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -1902,6 +1905,60 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^11.0.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@hapi/formula": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@hapi/hoek": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@hapi/pinpoint": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@hapi/tlds": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz",
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@hapi/topo": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^11.0.2"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
@@ -6844,7 +6901,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
@@ -6982,6 +7038,46 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/axios": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "30.3.0", "version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
@@ -7889,6 +7985,185 @@
"typedarray": "^0.0.6" "typedarray": "^0.0.6"
} }
}, },
"node_modules/concurrently": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/concurrently/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/concurrently/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/concurrently/node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/concurrently/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concurrently/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/concurrently/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/concurrently/node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/concurrently/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/conf": { "node_modules/conf": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz", "resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz",
@@ -9469,6 +9744,82 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-updater": {
"version": "6.8.9",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.7.0",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/builder-util-runtime": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron-winstaller": { "node_modules/electron-winstaller": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@@ -10378,6 +10729,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/fontkit": { "node_modules/fontkit": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
@@ -10563,6 +10935,19 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-east-asian-width": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -10810,7 +11195,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/hachure-fill": { "node_modules/hachure-fill": {
@@ -12696,6 +13080,25 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/joi": {
"version": "18.2.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/address": "^5.1.1",
"@hapi/formula": "^3.0.2",
"@hapi/hoek": "^11.0.7",
"@hapi/pinpoint": "^2.0.1",
"@hapi/tlds": "^1.1.1",
"@hapi/topo": "^6.0.2",
"@standard-schema/spec": "^1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/jpeg-exif": { "node_modules/jpeg-exif": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
@@ -12714,7 +13117,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -12980,7 +13382,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/leven": { "node_modules/leven": {
@@ -13357,6 +13758,19 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -15183,6 +15597,16 @@
"signal-exit": "^3.0.2" "signal-exit": "^3.0.2"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -15758,6 +16182,16 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -15958,6 +16392,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -16739,6 +17186,12 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -16878,6 +17331,16 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/truncate-utf8-bytes": { "node_modules/truncate-utf8-bytes": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -17441,6 +17904,26 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/wait-on": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
"integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.16.0",
"joi": "^18.2.1",
"lodash": "^4.18.1",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
},
"bin": {
"wait-on": "bin/wait-on"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+51 -9
View File
@@ -1,10 +1,15 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.4.2", "version": "5.0.1",
"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/index.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"dev:renderer": "vite --config vite.renderer.config.ts",
"dev:electron": "wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 ELECTRON_DISABLE_SANDBOX=1 electron . -- --no-sandbox --disable-gpu --disable-software-rasterizer --disable-dev-shm-usage",
"dev": "concurrently -k -n vite,electron -c blue,green \"npm:dev:renderer\" \"npm:dev:electron\"",
"build:renderer": "vite build --config vite.renderer.config.ts",
"preview": "npm run build:renderer && electron .",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
@@ -27,7 +32,9 @@
"dist": "electron-builder --publish=never", "dist": "electron-builder --publish=never",
"dist:all": "electron-builder -mwl", "dist:all": "electron-builder -mwl",
"download-tools": "node scripts/download-tools.js", "download-tools": "node scripts/download-tools.js",
"generate-icons": "node scripts/generate-icons.js" "generate-icons": "node scripts/generate-icons.js",
"build:icon-icns": "node scripts/build-icon-icns.js",
"publish:concreteinfo": "node scripts/publish-concreteinfo.js"
}, },
"keywords": [ "keywords": [
"markdown", "markdown",
@@ -60,6 +67,7 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^10.0.3",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"electron": "^41.1.1", "electron": "^41.1.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
@@ -78,7 +86,8 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.8" "vitest": "^4.1.8",
"wait-on": "^9.0.10"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
@@ -118,6 +127,7 @@
"docx4js": "^2.0.1", "docx4js": "^2.0.1",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"electron-updater": "^6.8.9",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.11.0", "figlet": "^1.11.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -159,15 +169,20 @@
}, },
"icon": "assets/icon", "icon": "assets/icon",
"files": [ "files": [
"src/**/*", "src/main/**/*",
"assets/**/*", "src/preload.js",
"scripts/**/*", "src/plugins/**/*",
"node_modules/**/*",
"package.json" "package.json"
], ],
"asarUnpack": [ "asarUnpack": [
"node_modules/ffmpeg-static/**" "node_modules/ffmpeg-static/**"
], ],
"extraResources": [
{
"from": "dist/renderer",
"to": "renderer"
}
],
"extraFiles": [], "extraFiles": [],
"fileAssociations": [ "fileAssociations": [
{ {
@@ -194,7 +209,28 @@
], ],
"mac": { "mac": {
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"identity": null "identity": null,
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"icon": "assets/icon.icns",
"darkModeSupport": true,
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": null
}, },
"win": { "win": {
"target": [ "target": [
@@ -267,6 +303,12 @@
], ],
"description": "Professional Markdown editor and universal file converter", "description": "Professional Markdown editor and universal file converter",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>" "maintainer": "ConcreteInfo <amit.wh@gmail.com>"
},
"publish": {
"provider": "github",
"owner": "amitwh",
"repo": "markdown-converter",
"releaseType": "release"
} }
} }
} }
+75
View File
@@ -0,0 +1,75 @@
/**
* Build assets/icon.icns from the PNGs in assets/icons/.
*
* ICNS file format (Apple Icon Image):
* Header: 'icns' (4 bytes) + total file length (uint32 BE, includes header)
* For each size:
* Type tag (4 bytes) + length (uint32 BE, includes the 8-byte entry header) + PNG data
*
* Standard PNG type tags we'll include:
* icp4 = 16x16, icp5 = 32x32, icp6 = 64x64
* ic07 = 128x128, ic08 = 256x256, ic09 = 512x512, ic10 = 1024x1024
* icsb = 48x48 (small)
* ic11 = 16x16@2x (32x32 retina), ic12 = 32x32@2x (64x64 retina)
* ic13 = 128x128@2x (256x256 retina), ic14 = 256x256@2x (512x512 retina)
*/
const fs = require('fs');
const path = require('path');
const iconsDir = path.join(__dirname, '..', 'assets', 'icons');
const outFile = path.join(__dirname, '..', 'assets', 'icon.icns');
// (size-in-pixels, icns type tag) — we skip sizes we don't have on disk
const sizes = [
{ size: 16, type: 'icp4' },
{ size: 32, type: 'icp5' },
{ size: 64, type: 'icp6' },
{ size: 128, type: 'ic07' },
{ size: 256, type: 'ic08' },
{ size: 512, type: 'ic09' },
{ size: 1024, type: 'ic10' },
{ size: 48, type: 'icsb' },
{ size: 32, type: 'ic11' }, // 16@2x
{ size: 64, type: 'ic12' }, // 32@2x
{ size: 256, type: 'ic13' }, // 128@2x
{ size: 512, type: 'ic14' }, // 256@2x
];
const entries = [];
for (const { size, type } of sizes) {
const pngPath = path.join(iconsDir, `${size}x${size}.png`);
if (!fs.existsSync(pngPath)) continue;
const png = fs.readFileSync(pngPath);
// ICNS requires 8-byte alignment per entry. paddedLen is the TOTAL entry size
// (8-byte type+length header + PNG + padding), rounded up to a multiple of 8.
const paddedLen = 8 + Math.ceil(png.length / 8) * 8;
entries.push({ type, png, paddedLen });
console.log(` ${type} (${size}x${size}) — ${png.length} bytes`);
}
if (entries.length === 0) {
console.error('No source PNGs found in', iconsDir);
process.exit(1);
}
const totalLen = 8 + entries.reduce((s, e) => s + e.paddedLen, 0);
const buf = Buffer.alloc(totalLen);
let off = 0;
// Header
buf.write('icns', off, 4, 'ascii'); off += 4;
buf.writeUInt32BE(totalLen, off); off += 4;
// Entries
for (const { type, png, paddedLen } of entries) {
buf.write(type, off, 4, 'ascii'); off += 4;
buf.writeUInt32BE(paddedLen, off); off += 4;
png.copy(buf, off);
off += png.length;
// Pad to 8-byte boundary within the entry. paddedLen already includes the 8-byte header.
const pad = paddedLen - 8 - png.length;
if (pad > 0) off += pad;
}
fs.writeFileSync(outFile, buf);
console.log(`\nWrote ${outFile} (${buf.length} bytes, ${entries.length} sizes)`);
+31 -4
View File
@@ -14,6 +14,30 @@ const { execSync } = require('child_process');
const PANDOC_VERSION = '3.9.0.2'; const PANDOC_VERSION = '3.9.0.2';
/**
* Pandoc's archive inner layout has shifted across releases and platforms:
* linux tarball: pandoc-3.9.0.2/bin/pandoc
* win32 zip: pandoc-3.9.0.2/pandoc.exe
* darwin zip: pandoc-3.9.0.2-x86_64/bin/pandoc (arch-suffixed inner dir)
* pandoc-3.9.0.2-arm64/bin/pandoc (on Apple Silicon builds)
* Rather than hard-coding the intermediate path — which has already broken
* once when pandoc 3.9 added the arch suffix to the macOS archive — we walk
* the extracted tree and find the binary by name. This is robust to future
* layout shifts (e.g., universal binaries renaming the inner dir).
*/
function findFile(rootDir, targetName) {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(rootDir, entry.name);
if (entry.isFile() && entry.name === targetName) return full;
if (entry.isDirectory()) {
const found = findFile(full, targetName);
if (found) return found;
}
}
return null;
}
const PANDOC_CONFIG = { const PANDOC_CONFIG = {
linux: { linux: {
url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`, url: `https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz`,
@@ -22,8 +46,9 @@ const PANDOC_CONFIG = {
extract(archivePath, destDir) { extract(archivePath, destDir) {
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`); const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true });
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}" pandoc-${PANDOC_VERSION}/bin/pandoc`); execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`);
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc'); const src = findFile(tmpDir, 'pandoc');
if (!src) throw new Error(`pandoc binary not found under ${tmpDir}`);
fs.copyFileSync(src, path.join(destDir, 'pandoc')); fs.copyFileSync(src, path.join(destDir, 'pandoc'));
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755); fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -39,7 +64,8 @@ const PANDOC_CONFIG = {
execSync( execSync(
`powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`, `powershell -Command "Expand-Archive -Force '${archivePath}' '${tmpDir}'"`,
); );
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'pandoc.exe'); const src = findFile(tmpDir, 'pandoc.exe');
if (!src) throw new Error(`pandoc.exe not found under ${tmpDir}`);
fs.copyFileSync(src, path.join(destDir, 'pandoc.exe')); fs.copyFileSync(src, path.join(destDir, 'pandoc.exe'));
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}, },
@@ -52,7 +78,8 @@ const PANDOC_CONFIG = {
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`); const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true });
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`); execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc'); const src = findFile(tmpDir, 'pandoc');
if (!src) throw new Error(`pandoc binary not found under ${tmpDir}`);
fs.copyFileSync(src, path.join(destDir, 'pandoc')); fs.copyFileSync(src, path.join(destDir, 'pandoc'));
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755); fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const version = process.argv[2] || require('../package.json').version;
const hook = process.env.CONCRETEINFO_DEPLOY_HOOK;
if (!hook) {
console.error('CONCRETEINFO_DEPLOY_HOOK not set');
process.exit(1);
}
const candidates = [
'latest-mac.yml',
'latest-linux.yml',
'latest-windows.yml',
`MarkdownConverter-${version}.dmg`,
`markdown-converter_${version}_amd64.deb`,
`MarkdownConverter-Setup-${version}.exe`,
];
const dist = path.resolve(__dirname, '..', 'dist');
for (const f of candidates) {
if (!fs.existsSync(path.join(dist, f))) {
console.error(`missing ${f} — run electron-builder first`);
process.exit(1);
}
}
const form = new FormData();
form.append('version', version);
for (const f of candidates) {
if (fs.existsSync(path.join(dist, f))) {
form.append('artifacts', fs.createReadStream(path.join(dist, f)), f);
}
}
fetch('https://updates.concreteinfo.co.in/api/v1/ingest', {
method: 'POST',
headers: { Authorization: `Bearer ${hook}` },
body: form,
})
.then((r) => {
console.log('ingest status', r.status);
process.exit(r.ok ? 0 : 1);
})
.catch((e) => {
console.error('ingest failed:', e);
process.exit(1);
});
+148
View File
@@ -0,0 +1,148 @@
import { _electron as electron } from 'playwright-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
const APP_DIR = '/home/amith/apps/markdown-converter';
const electronBin = path.join(APP_DIR, 'node_modules/electron/dist/electron');
const testMd = '/tmp/verify-test.md';
const outputDocx = '/tmp/verify-output.docx';
const outputHtml = '/tmp/verify-output.html';
// Cleanup previous outputs
if (fs.existsSync(testMd)) fs.unlinkSync(testMd);
if (fs.existsSync(outputDocx)) fs.unlinkSync(outputDocx);
if (fs.existsSync(outputHtml)) fs.unlinkSync(outputHtml);
// Write test Markdown file
fs.writeFileSync(
testMd,
'# Test Document\n\nThis is a verification test for opening and exporting.\n\n- Point A\n- Point B\n'
);
console.log('Launching Electron...');
const app = await electron.launch({
executablePath: electronBin,
args: [
'--no-sandbox',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-dev-shm-usage',
'.',
],
env: {
...process.env,
DISPLAY: ':0',
ELECTRON_DISABLE_SANDBOX: '1',
VITE_DEV_SERVER_URL: 'http://localhost:5173',
},
cwd: APP_DIR,
});
app.process().stdout.on('data', (data) => console.log('[MAIN-OUT]', data.toString().trim()));
app.process().stderr.on('data', (data) => console.log('[MAIN-ERR]', data.toString().trim()));
const win = await app.firstWindow();
await win.waitForLoadState('domcontentloaded');
await win.waitForSelector('.cm-editor, [role="toolbar"]', { timeout: 10000 });
console.log('App loaded.');
// Dismiss welcome wizard if present
const wizardCount = await win.locator('[data-testid="first-run-wizard"]').count();
if (wizardCount > 0) {
console.log('Dismissing first run wizard...');
await win.click('[data-testid="first-run-wizard"] >> text=Skip');
await new Promise((r) => setTimeout(r, 300));
}
// Stub dialog.showSaveDialogSync in main process to automatically return output paths
await app.evaluate(({ dialog }, { outputDocx, outputHtml }) => {
dialog.showSaveDialogSync = (window, options) => {
const filters = options?.filters || [];
if (filters.some(f => f.extensions.includes('docx'))) {
return outputDocx;
}
return outputHtml;
};
}, { outputDocx, outputHtml });
// Simulate opening the test Md file by sending IPC from main
console.log('Opening test markdown file...');
const fileContent = fs.readFileSync(testMd, 'utf-8');
await app.evaluate(({ BrowserWindow }, { filePath, content }) => {
const wins = BrowserWindow.getAllWindows();
const main = wins.find((w) => !w.isDestroyed());
if (!main) throw new Error('No main window');
main.webContents.send('file-opened', { path: filePath, content });
}, { filePath: testMd, content: fileContent });
// Wait for editor to display the content
await win.waitForFunction(
(content) => {
const editor = document.querySelector('.cm-content');
return editor && editor.textContent.includes('Test Document');
},
fileContent,
{ timeout: 5000 }
);
console.log('File successfully opened in editor.');
// Let's wait a moment for currentFile synchronization to trigger in the main process
await new Promise((r) => setTimeout(r, 500));
// Trigger DOCX Export (calls performExportWithOptions under the hood)
console.log('Exporting to DOCX...');
await win.evaluate(() => {
window.electronAPI.export.withOptions('docx', {});
});
// Wait for file to be written to disk
let docxExported = false;
for (let i = 0; i < 20; i++) {
if (fs.existsSync(outputDocx) && fs.statSync(outputDocx).size > 0) {
docxExported = true;
break;
}
await new Promise((r) => setTimeout(r, 250));
}
if (docxExported) {
console.log('✅ DOCX exported successfully.');
} else {
console.error('❌ DOCX export failed (file not created or empty).');
}
// Wait 2.5 seconds to bypass the conversion rate limiter (2000ms debounce)
console.log('Waiting for rate limiter...');
await new Promise((r) => setTimeout(r, 2500));
// Trigger HTML Export
console.log('Exporting to HTML...');
await win.evaluate(() => {
window.electronAPI.export.withOptions('html', {});
});
let htmlExported = false;
for (let i = 0; i < 20; i++) {
if (fs.existsSync(outputHtml) && fs.statSync(outputHtml).size > 0) {
htmlExported = true;
break;
}
await new Promise((r) => setTimeout(r, 250));
}
if (htmlExported) {
console.log('✅ HTML exported successfully.');
} else {
console.error('❌ HTML export failed (file not created or empty).');
}
await app.close();
console.log('Verification completed.');
if (docxExported && htmlExported) {
process.exit(0);
} else {
process.exit(1);
}
+89 -89
View File
@@ -12,100 +12,100 @@
* @type {import('../types').FileSystemAdapter} * @type {import('../types').FileSystemAdapter}
*/ */
const electronFsAdapter = { const electronFsAdapter = {
/** /**
* Read file content * Read file content
* @param {string} path - File path * @param {string} path - File path
* @returns {Promise<string>} File content * @returns {Promise<string>} File content
*/ */
async readFile(path) { async readFile(path) {
return await window.electronAPI.file.read(path); return await window.electronAPI.file.read(path);
}, },
/** /**
* Write content to file * Write content to file
* @param {string} path - File path * @param {string} path - File path
* @param {string} content - File content * @param {string} content - File content
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async writeFile(path, content) { async writeFile(path, content) {
return await window.electronAPI.file.write(path, content); return await window.electronAPI.file.write(path, content);
}, },
/** /**
* Delete file * Delete file
* @param {string} path - File path * @param {string} path - File path
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async deleteFile(path) { async deleteFile(path) {
return await window.electronAPI.file.delete(path); return await window.electronAPI.file.delete(path);
}, },
/** /**
* Ensure directory exists * Ensure directory exists
* @param {string} path - Directory path * @param {string} path - Directory path
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async ensureDir(path) { async ensureDir(path) {
return await window.electronAPI.file.ensureDir(path); return await window.electronAPI.file.ensureDir(path);
}, },
/** /**
* List directory contents * List directory contents
* @param {string} path - Directory path * @param {string} path - Directory path
* @returns {Promise<Array<import('../types').FileInfo>>} * @returns {Promise<Array<import('../types').FileInfo>>}
*/ */
async listDirectory(path) { async listDirectory(path) {
const result = await window.electronAPI.invoke('list-directory', path); const result = await window.electronAPI.invoke('list-directory', path);
if (!result?.entries) { if (!result?.entries) {
return []; return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path
}));
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
return await window.electronAPI.file.exists(path);
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
return await window.electronAPI.file.isDirectory(path);
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
return await window.electronAPI.file.copy(source, dest);
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
} }
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path,
}));
},
/**
* Check if path exists
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async exists(path) {
return await window.electronAPI.file.exists(path);
},
/**
* Check if path is a directory
* @param {string} path - Path to check
* @returns {Promise<boolean>}
*/
async isDirectory(path) {
return await window.electronAPI.file.isDirectory(path);
},
/**
* Copy file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async copy(source, dest) {
return await window.electronAPI.file.copy(source, dest);
},
/**
* Move file or directory
* @param {string} source - Source path
* @param {string} dest - Destination path
* @returns {Promise<void>}
*/
async move(source, dest) {
return await window.electronAPI.file.move(source, dest);
},
}; };
module.exports = { electronFsAdapter }; module.exports = { electronFsAdapter };
+1 -1
View File
@@ -129,5 +129,5 @@
*/ */
module.exports = { module.exports = {
// Type definitions are JSDoc only, no runtime exports needed // Type definitions are JSDoc only, no runtime exports needed
}; };
+41 -31
View File
@@ -5,19 +5,19 @@
const { analyze } = require('./writing-analytics'); const { analyze } = require('./writing-analytics');
function showAnalyticsModal(tabManager) { function showAnalyticsModal(tabManager) {
const existing = document.getElementById('analytics-modal'); const existing = document.getElementById('analytics-modal');
if (existing) existing.remove(); if (existing) existing.remove();
const content = tabManager.getEditorContent(); const content = tabManager.getEditorContent();
const metrics = analyze(content); const metrics = analyze(content);
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'analytics-modal'; overlay.id = 'analytics-modal';
overlay.className = 'analytics-overlay'; overlay.className = 'analytics-overlay';
const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1; const maxCount = metrics.topWords.length > 0 ? metrics.topWords[0].count : 1;
overlay.innerHTML = ` overlay.innerHTML = `
<div class="analytics-modal"> <div class="analytics-modal">
<div class="analytics-header"> <div class="analytics-header">
<h2>Writing Analytics</h2> <h2>Writing Analytics</h2>
@@ -61,11 +61,15 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Avg Sentence</span> <span class="analytics-label">Avg Sentence</span>
<span class="analytics-value">${metrics.avgSentenceLength} words</span> <span class="analytics-value">${metrics.avgSentenceLength} words</span>
</div> </div>
${metrics.longestSentenceLength > 0 ? ` ${
metrics.longestSentenceLength > 0
? `
<div class="analytics-row analytics-longest"> <div class="analytics-row analytics-longest">
<span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span> <span class="analytics-label">Longest (${metrics.longestSentenceLength} words)</span>
<span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span> <span class="analytics-value analytics-sentence-preview">${escapeHtml(metrics.longestSentence)}</span>
</div>` : ''} </div>`
: ''
}
</div> </div>
<div class="analytics-section"> <div class="analytics-section">
@@ -74,39 +78,45 @@ function showAnalyticsModal(tabManager) {
<span class="analytics-label">Unique</span> <span class="analytics-label">Unique</span>
<span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span> <span class="analytics-value">${metrics.uniqueWordCount} / ${metrics.wordCount}<small>${metrics.lexicalDiversity}%</small></span>
</div> </div>
${metrics.topWords.length > 0 ? ` ${
metrics.topWords.length > 0
? `
<div class="word-cloud"> <div class="word-cloud">
${metrics.topWords.map(w => { ${metrics.topWords
.map((w) => {
const scale = 13 + Math.round((w.count / maxCount) * 3); 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>`; return `<span class="word-tag" style="font-size:${scale}px">${escapeHtml(w.word)}<small>${w.count}</small></span>`;
}).join('')} })
</div>` : ''} .join('')}
</div>`
: ''
}
</div> </div>
</div> </div>
</div> </div>
`; `;
const closeBtn = overlay.querySelector('.analytics-close'); const closeBtn = overlay.querySelector('.analytics-close');
closeBtn.addEventListener('click', () => overlay.remove()); closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove(); if (e.target === overlay) overlay.remove();
}); });
const escHandler = (e) => { const escHandler = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
overlay.remove(); overlay.remove();
document.removeEventListener('keydown', escHandler); document.removeEventListener('keydown', escHandler);
} }
}; };
document.addEventListener('keydown', escHandler); document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }
function escapeHtml(str) { function escapeHtml(str) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = str; div.textContent = str;
return div.innerHTML; return div.innerHTML;
} }
module.exports = { showAnalyticsModal }; module.exports = { showAnalyticsModal };
+177 -104
View File
@@ -4,124 +4,197 @@
*/ */
const STOP_WORDS = new Set([ const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'the',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'a',
'could', 'should', 'to', 'of', 'in', 'for', 'on', 'with', 'an',
'at', 'by', 'from', 'as', 'and', 'or', 'but', 'if', 'it', 'is',
'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'are',
'we', 'our', 'you', 'your', 'he', 'him', 'his', 'she', 'her', 'was',
'they', 'them', 'their', 'not', 'no', 'so', 'than', 'too', 'were',
'very', 'also', 'just', 'about', 'up', 'out', 'what', 'which', 'who' '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) { function countSyllables(word) {
word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); word = word.toLowerCase().replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '');
word = word.replace(/^y/, ''); word = word.replace(/^y/, '');
return word.match(/[aeiouy]{1,2}/gi)?.length || 1; return word.match(/[aeiouy]{1,2}/gi)?.length || 1;
} }
function extractWords(text) { function extractWords(text) {
return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || []; return text.match(/[a-zA-Z]+(?:['-][a-zA-Z]+)*/g) || [];
} }
function getReadabilityLabel(score) { function getReadabilityLabel(score) {
if (score >= 90) return 'Very Easy'; if (score >= 90) return 'Very Easy';
if (score >= 70) return 'Easy'; if (score >= 70) return 'Easy';
if (score >= 50) return 'Standard'; if (score >= 50) return 'Standard';
if (score >= 30) return 'Difficult'; if (score >= 30) return 'Difficult';
return 'Very Difficult'; return 'Very Difficult';
} }
function analyze(text) { function analyze(text) {
if (!text || !text.trim()) { 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 { return {
wordCount, wordCount: 0,
sentenceCount, sentenceCount: 0,
paragraphCount, paragraphCount: 0,
fleschEase, fleschEase: 0,
fleschGrade, fleschGrade: 0,
readabilityLabel, readabilityLabel: 'N/A',
readingTime, readingTime: 0,
speakingTime, speakingTime: 0,
uniqueWordCount, uniqueWordCount: 0,
lexicalDiversity, lexicalDiversity: 0,
avgSentenceLength, avgSentenceLength: 0,
longestSentence, longestSentence: '',
longestSentenceLength, longestSentenceLength: 0,
topWords 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 }; module.exports = { analyze };
-601
View File
@@ -1,601 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCII Art Generator - MarkdownConverter</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--ci-dark-gray: #464646;
--ci-medium-gray: #9a9696;
--ci-accent: #e5461f;
--ci-light-gray: #e3e3e3;
--ci-black: #0d0b09;
--ci-white: #ffffff;
--ci-bg: #f5f5f5;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--ci-bg);
color: var(--ci-dark-gray);
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, var(--ci-dark-gray) 0%, var(--ci-black) 100%);
padding: 16px 24px;
border-bottom: 3px solid var(--ci-accent);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
color: var(--ci-white);
font-size: 1.25rem;
font-weight: 600;
}
.container {
padding: 24px;
max-width: 100%;
}
.section {
background: var(--ci-white);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.section-title {
font-weight: 600;
margin-bottom: 16px;
color: var(--ci-dark-gray);
font-size: 0.95rem;
}
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.mode-tab {
padding: 10px 20px;
border: 2px solid var(--ci-light-gray);
background: var(--ci-white);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab:hover {
border-color: var(--ci-accent);
}
.mode-tab.active {
background: var(--ci-accent);
border-color: var(--ci-accent);
color: var(--ci-white);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--ci-dark-gray);
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 10px 14px;
border: 2px solid var(--ci-light-gray);
border-radius: 8px;
font-size: 0.875rem;
font-family: inherit;
transition: border-color 0.2s;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--ci-accent);
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
.preview-container {
background: var(--ci-black);
border-radius: 8px;
padding: 16px;
min-height: 200px;
max-height: 350px;
overflow: auto;
}
.preview-content {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.3;
color: #00ff00;
white-space: pre;
}
.template-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.template-btn {
padding: 8px 12px;
border: 2px solid var(--ci-light-gray);
background: var(--ci-white);
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.template-btn:hover {
border-color: var(--ci-accent);
background: rgba(229, 70, 31, 0.05);
}
.template-btn.active {
border-color: var(--ci-accent);
background: rgba(229, 70, 31, 0.1);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
background: var(--ci-white);
border-top: 1px solid var(--ci-light-gray);
position: sticky;
bottom: 0;
}
.btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: var(--ci-accent);
color: var(--ci-white);
}
.btn-primary:hover {
background: #c93a18;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--ci-light-gray);
color: var(--ci-dark-gray);
}
.btn-secondary:hover {
background: #d0d0d0;
}
.mode-section {
display: none;
}
.mode-section.active {
display: block;
}
.template-category {
margin-bottom: 16px;
}
.template-category-title {
font-size: 0.8rem;
color: var(--ci-medium-gray);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>
</head>
<body>
<div class="header">
<h1>ASCII Art Generator</h1>
</div>
<div class="container">
<div class="section">
<div class="mode-tabs">
<button class="mode-tab active" data-mode="text">Text Banner</button>
<button class="mode-tab" data-mode="box">Box/Frame</button>
<button class="mode-tab" data-mode="templates">Templates</button>
</div>
<!-- Text Banner Mode -->
<div id="text-mode" class="mode-section active">
<div class="form-group">
<label class="form-label">Text to Convert</label>
<input type="text" id="text-input" class="form-input" placeholder="Enter your text..." maxlength="30">
</div>
<div class="form-group">
<label class="form-label">Style</label>
<select id="font-style" class="form-select">
<option value="standard">Standard</option>
<option value="banner">Banner</option>
<option value="block">Block</option>
<option value="bubble">Bubble</option>
<option value="digital">Digital</option>
</select>
</div>
</div>
<!-- Box/Frame Mode -->
<div id="box-mode" class="mode-section">
<div class="form-group">
<label class="form-label">Text Content</label>
<textarea id="box-text" class="form-textarea" placeholder="Enter text for the box..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Box Style</label>
<select id="box-style" class="form-select">
<option value="single">Single Line</option>
<option value="double">Double Line</option>
<option value="rounded">Rounded</option>
<option value="bold">Bold</option>
<option value="ascii">ASCII (+|-)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Padding</label>
<input type="number" id="box-padding" class="form-input" min="0" max="10" value="2" style="width: 100px;">
</div>
</div>
<!-- Templates Mode -->
<div id="templates-mode" class="mode-section">
<div class="template-category">
<div class="template-category-title">Arrows & Flow</div>
<div class="template-grid">
<button class="template-btn" data-template="arrow-right">Arrow Right</button>
<button class="template-btn" data-template="arrow-down">Arrow Down</button>
<button class="template-btn" data-template="decision">Decision</button>
<button class="template-btn" data-template="process">Process Flow</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Diagrams</div>
<div class="template-grid">
<button class="template-btn" data-template="flowchart">Flowchart</button>
<button class="template-btn" data-template="sequence">Sequence</button>
<button class="template-btn" data-template="network">Network</button>
<button class="template-btn" data-template="hierarchy">Hierarchy</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Boxes & Containers</div>
<div class="template-grid">
<button class="template-btn" data-template="header">Header</button>
<button class="template-btn" data-template="note">Note Box</button>
<button class="template-btn" data-template="warning">Warning</button>
<button class="template-btn" data-template="info">Info Box</button>
</div>
</div>
<div class="template-category">
<div class="template-category-title">Decorative</div>
<div class="template-grid">
<button class="template-btn" data-template="divider">Divider</button>
<button class="template-btn" data-template="separator">Separator</button>
<button class="template-btn" data-template="banner">Banner</button>
<button class="template-btn" data-template="checklist">Checklist</button>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Preview</div>
<div class="preview-container">
<div id="preview" class="preview-content"></div>
</div>
</div>
</div>
<div class="footer">
<button class="btn btn-secondary" id="btn-generate">Generate Preview</button>
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
</div>
<script>
// ASCII Art Generator Logic
const FONTS = {
standard: {
height: 5,
chars: {
'A': [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
'B': ['|----\\', '| |', '|----/', '| \\', '|----/'],
'C': ['/----\\', '| ', '| ', '| ', '\\----/'],
'D': ['|----\\', '| |', '| |', '| |', '|----/'],
'E': ['|----', '| ', '|--- ', '| ', '|----'],
'F': ['|----', '| ', '|--- ', '| ', '| '],
'G': ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
'H': ['| |', '| |', '|----/', '| |', '| |'],
'I': ['|---|', ' | ', ' | ', ' | ', '|---|'],
'J': [' |', ' |', ' |', '| |', '\\---/'],
'K': ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
'L': ['| ', '| ', '| ', '| ', '|----'],
'M': ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
'N': ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
'O': ['/----\\', '| |', '| |', '| |', '\\----/'],
'P': ['|----\\', '| |', '|----/', '| ', '| '],
'Q': ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
'R': ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
'S': ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
'T': ['-----', ' | ', ' | ', ' | ', ' | '],
'U': ['| |', '| |', '| |', '| |', '\\----/'],
'V': ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
'W': ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
'X': ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
'Y': ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
'Z': ['-----', ' / ', ' / ', ' / ', '-----'],
' ': [' ', ' ', ' ', ' ', ' '],
'0': ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
'1': [' /| ', ' / | ', ' | ', ' | ', ' ----'],
'2': ['/---\\', ' |', ' ---/', '/ ', '-----'],
'3': ['----\\', ' |', ' ---/', ' |', '----/'],
'4': ['| |', '| |', '-----', ' |', ' |'],
'5': ['-----', '| ', '----\\', ' |', '----/'],
'6': ['/----', '| ', '|---\\', '| |', '\\---/'],
'7': ['-----', ' / ', ' / ', ' / ', '/ '],
'8': ['/---\\', '| |', ' --- ', '| |', '\\---/'],
'9': ['/---\\', '| |', '\\----', ' |', '----/']
}
},
banner: {
height: 7,
chars: {
'A': [' ##### ', ' ## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
'B': ['######## ', '## ##', '## ##', '######## ', '## ##', '## ##', '######## '],
'C': [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
'D': ['######## ', '## ##', '## ##', '## ##', '## ##', '## ##', '######## '],
'E': ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
'F': ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
'G': [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
'H': ['## ##', '## ##', '## ##', '#########', '## ##', '## ##', '## ##'],
'I': ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
'J': [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
'K': ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
'L': ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
'M': ['## ##', '### ###', '#### ####', '## ### ##', '## ##', '## ##', '## ##'],
'N': ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
'O': [' ####### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
'P': ['######## ', '## ##', '## ##', '######## ', '## ', '## ', '## '],
'Q': [' ####### ', '## ##', '## ##', '## ##', '## ## ##', '## ## ', ' ##### ##'],
'R': ['######## ', '## ##', '## ##', '######## ', '## ## ', '## ## ', '## ##'],
'S': [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
'T': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
'U': ['## ##', '## ##', '## ##', '## ##', '## ##', '## ##', ' ####### '],
'V': ['## ##', '## ##', '## ##', '## ##', ' ## ## ', ' ## ## ', ' ### '],
'W': ['## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', '## ## ##', ' ### ### '],
'X': ['## ##', ' ## ## ', ' ## ## ', ' ### ', ' ## ## ', ' ## ## ', '## ##'],
'Y': ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
'Z': ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
'0': [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
'1': [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
'2': [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
'3': [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
'4': [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
'5': ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
'6': [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
'7': ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
'8': [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
'9': [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### ']
}
},
block: {
height: 6,
chars: {
'A': ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'B': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
'C': ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
'D': ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
'E': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
'F': ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
'G': ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
'H': ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
'I': ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
'J': [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
'K': ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
'L': ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
'M': ['███╗ ███╗', '████╗ ████║', '██╔████╔██║', '██║╚██╔╝██║', '██║ ╚═╝ ██║', '╚═╝ ╚═╝'],
'N': ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
'O': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
'P': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
'Q': ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
'R': ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
'S': ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
'T': ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
'U': ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
'V': ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
'W': ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
'X': ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
'Y': ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
'Z': ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
' ': [' ', ' ', ' ', ' ', ' ', ' ']
}
}
};
const TEMPLATES = {
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
'decision': ' ╱╲\n ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
'process': '┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
'flowchart': '┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
'sequence': ' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
'network': ' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
'hierarchy': ' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
'header': '╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
'note': '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
'warning': '╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
'info': '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
'divider': '════════════════════════════════════════',
'separator': '╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
'banner': '★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
'checklist': '☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed'
};
const BOX_STYLES = {
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }
};
let currentMode = 'text';
let currentTemplate = null;
// Mode switching
document.querySelectorAll('.mode-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
document.querySelectorAll('.mode-section').forEach(s => s.classList.remove('active'));
document.getElementById(currentMode + '-mode').classList.add('active');
generatePreview();
});
});
// Template selection
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTemplate = btn.dataset.template;
generatePreview();
});
});
// Generate text banner
function generateTextBanner(text, style) {
const font = FONTS[style] || FONTS.standard;
const lines = Array(font.height).fill('');
for (const char of text.toUpperCase()) {
const charArt = font.chars[char] || font.chars[' '];
if (charArt) {
for (let i = 0; i < font.height; i++) {
lines[i] += charArt[i] + ' ';
}
}
}
return lines.join('\n');
}
// Generate box
function generateBox(text, style, padding) {
const box = BOX_STYLES[style] || BOX_STYLES.single;
const lines = text.split('\n');
const maxLen = Math.max(...lines.map(l => l.length)) + padding * 2;
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
// Add padding lines at top
for (let i = 0; i < Math.floor(padding / 2); i++) {
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
}
// Add text lines
for (const line of lines) {
const paddedLine = ' '.repeat(padding) + line.padEnd(maxLen - padding) + ' ';
result += box.v + ' ' + paddedLine + box.v + '\n';
}
// Add padding lines at bottom
for (let i = 0; i < Math.floor(padding / 2); i++) {
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
}
result += box.bl + box.h.repeat(maxLen + 2) + box.br;
return result;
}
// Generate preview
function generatePreview() {
let result = '';
if (currentMode === 'text') {
const text = document.getElementById('text-input').value || 'HELLO';
const style = document.getElementById('font-style').value;
result = generateTextBanner(text, style);
} else if (currentMode === 'box') {
const text = document.getElementById('box-text').value || 'Your text here';
const style = document.getElementById('box-style').value;
const padding = parseInt(document.getElementById('box-padding').value) || 2;
result = generateBox(text, style, padding);
} else if (currentMode === 'templates') {
result = currentTemplate ? TEMPLATES[currentTemplate] : 'Select a template...';
}
document.getElementById('preview').textContent = result;
}
// Event listeners for live preview
document.getElementById('text-input').addEventListener('input', generatePreview);
document.getElementById('font-style').addEventListener('change', generatePreview);
document.getElementById('box-text').addEventListener('input', generatePreview);
document.getElementById('box-style').addEventListener('change', generatePreview);
document.getElementById('box-padding').addEventListener('input', generatePreview);
document.getElementById('btn-generate').addEventListener('click', generatePreview);
document.getElementById('btn-insert').addEventListener('click', () => {
const content = document.getElementById('preview').textContent;
if (content && window.electronAPI) {
// Wrap in code block for markdown
const wrapped = '```\n' + content + '\n```';
window.electronAPI.send('insert-generated-content', wrapped);
window.close();
}
});
// Initial preview
generatePreview();
</script>
</body>
</html>
-109
View File
@@ -1,109 +0,0 @@
class CommandPalette {
constructor() {
this.overlay = document.getElementById('command-palette-overlay');
this.input = document.getElementById('command-palette-input');
this.results = document.getElementById('command-palette-results');
this.commands = [];
this.selectedIndex = 0;
this.filteredCommands = [];
this.setupEventListeners();
}
register(label, shortcut, action) {
this.commands.push({ label, shortcut, action });
}
open() {
this.overlay.classList.remove('hidden');
this.input.value = '';
this.input.focus();
this.selectedIndex = 0;
this.renderResults('');
}
close() {
this.overlay.classList.add('hidden');
}
isOpen() {
return !this.overlay.classList.contains('hidden');
}
setupEventListeners() {
this.input.addEventListener('input', () => {
this.selectedIndex = 0;
this.renderResults(this.input.value);
});
this.overlay.addEventListener('click', (e) => {
if (e.target === this.overlay) this.close();
});
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.close();
} else if (e.key === 'Enter') {
e.preventDefault();
this.executeSelected();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
this.updateSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.updateSelection();
}
});
}
renderResults(query) {
this.filteredCommands = query
? this.commands.filter(cmd => cmd.label.toLowerCase().includes(query.toLowerCase()))
: [...this.commands];
this.results.innerHTML = this.filteredCommands.map((cmd, i) => `
<div class="command-item ${i === this.selectedIndex ? 'selected' : ''}" data-index="${i}">
<span class="command-label">${this.highlightMatch(cmd.label, query)}</span>
${cmd.shortcut ? `<span class="command-shortcut">${cmd.shortcut}</span>` : ''}
</div>
`).join('');
this.results.querySelectorAll('.command-item').forEach((el) => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.index);
this.filteredCommands[idx].action();
this.close();
});
el.addEventListener('mouseenter', () => {
this.selectedIndex = parseInt(el.dataset.index);
this.updateSelection();
});
});
}
highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<strong>$1</strong>');
}
updateSelection() {
this.results.querySelectorAll('.command-item').forEach((el, i) => {
el.classList.toggle('selected', i === this.selectedIndex);
});
// Scroll selected into view
const selected = this.results.querySelector('.command-item.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
executeSelected() {
if (this.filteredCommands[this.selectedIndex]) {
this.filteredCommands[this.selectedIndex].action();
this.close();
}
}
}
module.exports = { CommandPalette };
+36 -29
View File
@@ -12,38 +12,23 @@ const { EditorState } = require('@codemirror/state');
const { markdown, markdownLanguage } = require('@codemirror/lang-markdown'); const { markdown, markdownLanguage } = require('@codemirror/lang-markdown');
// Language extensions loaded lazily on first use // Language extensions loaded lazily on first use
let _javascript, _html, _css, _json, _python; let _javascript, _html, _css, _json, _python;
const { const { defaultKeymap, history, historyKeymap, indentWithTab } = require('@codemirror/commands');
defaultKeymap, const { searchKeymap, highlightSelectionMatches } = require('@codemirror/search');
history, const { autocompletion, completionKeymap } = require('@codemirror/autocomplete');
historyKeymap, const { bracketMatching, foldGutter, indentOnInput } = require('@codemirror/language');
indentWithTab,
} = require('@codemirror/commands');
const {
searchKeymap,
highlightSelectionMatches,
} = require('@codemirror/search');
const {
autocompletion,
completionKeymap,
} = require('@codemirror/autocomplete');
const {
bracketMatching,
foldGutter,
indentOnInput,
} = require('@codemirror/language');
const { oneDark } = require('@codemirror/theme-one-dark'); const { oneDark } = require('@codemirror/theme-one-dark');
// Custom theme for JetBrains Mono font // Custom theme for JetBrains Mono font
const jetBrainsMonoTheme = EditorView.theme({ const jetBrainsMonoTheme = EditorView.theme({
'&': { '&': {
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace" fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Courier New', monospace",
}, },
'.cm-content': { '.cm-content': {
fontFamily: 'inherit' fontFamily: 'inherit',
}, },
'.cm-scroller': { '.cm-scroller': {
fontFamily: 'inherit' fontFamily: 'inherit',
} },
}); });
/** /**
@@ -59,7 +44,14 @@ const jetBrainsMonoTheme = EditorView.theme({
* @returns {EditorView} the created editor view * @returns {EditorView} the created editor view
*/ */
function createEditor(parentElement, options = {}) { function createEditor(parentElement, options = {}) {
console.log('[createEditor] Called with parentElement:', parentElement?.id, 'dimensions:', parentElement?.clientWidth, 'x', parentElement?.clientHeight); console.log(
'[createEditor] Called with parentElement:',
parentElement?.id,
'dimensions:',
parentElement?.clientWidth,
'x',
parentElement?.clientHeight
);
if (!parentElement) { if (!parentElement) {
console.error('[createEditor] ERROR: parentElement is null or undefined!'); console.error('[createEditor] ERROR: parentElement is null or undefined!');
return null; return null;
@@ -125,11 +117,26 @@ function createEditor(parentElement, options = {}) {
*/ */
function getLanguageExtension(lang) { function getLanguageExtension(lang) {
const loaders = { const loaders = {
javascript: () => { if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript; return _javascript(); }, javascript: () => {
html: () => { if (!_html) _html = require('@codemirror/lang-html').html; return _html(); }, if (!_javascript) _javascript = require('@codemirror/lang-javascript').javascript;
css: () => { if (!_css) _css = require('@codemirror/lang-css').css; return _css(); }, return _javascript();
json: () => { if (!_json) _json = require('@codemirror/lang-json').json; return _json(); }, },
python: () => { if (!_python) _python = require('@codemirror/lang-python').python; return _python(); }, html: () => {
if (!_html) _html = require('@codemirror/lang-html').html;
return _html();
},
css: () => {
if (!_css) _css = require('@codemirror/lang-css').css;
return _css();
},
json: () => {
if (!_json) _json = require('@codemirror/lang-json').json;
return _json();
},
python: () => {
if (!_python) _python = require('@codemirror/lang-python').python;
return _python();
},
markdown: () => markdown({ base: markdownLanguage }), markdown: () => markdown({ base: markdownLanguage }),
}; };
loaders.js = loaders.javascript; loaders.js = loaders.javascript;
-75
View File
@@ -1,75 +0,0 @@
/* Local Font Definitions for MarkdownConverter */
/* Inter Font Family */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('../assets/fonts/Inter-Light.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/Inter-Bold.woff2') format('woff2');
}
/* JetBrains Mono Font Family - For code, markdown editor, and ASCII art */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../assets/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
-1667
View File
File diff suppressed because it is too large Load Diff
+58 -8
View File
@@ -7,9 +7,24 @@ function getGitInstance(dir) {
async function getStatus(dir) { async function getStatus(dir) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
return await git.status(); const result = await git.status();
} catch (err) { const files = [];
return { error: 'Not a git repository' }; for (const [filePath, status] of Object.entries(result.files || {})) {
files.push({
filePath,
status:
status.working_dir === 'M'
? 'modified'
: status.working_dir === 'A' || status.index === 'A'
? 'added'
: status.working_dir === 'D' || status.index === 'D'
? 'deleted'
: 'untracked',
});
}
return { files };
} catch (_err) {
return { files: [], error: 'Not a git repository' };
} }
} }
@@ -17,16 +32,32 @@ async function stage(dir, files) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
await git.add(files); await git.add(files);
return await git.status(); const result = await git.status();
const staged = [];
for (const [filePath, status] of Object.entries(result.files || {})) {
staged.push({
filePath,
status:
status.index === 'A'
? 'added'
: status.index === 'M'
? 'modified'
: status.index === 'D'
? 'deleted'
: 'untracked',
});
}
return { files: staged };
} catch (err) { } catch (err) {
return { error: err.message }; return { files: [], error: err.message };
} }
} }
async function commit(dir, message) { async function commit(dir, message) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
return await git.commit(message); const result = await git.commit(message);
return { summary: result?.summary || 'Committed' };
} catch (err) { } catch (err) {
return { error: err.message }; return { error: err.message };
} }
@@ -35,10 +66,29 @@ async function commit(dir, message) {
async function log(dir, maxCount = 20) { async function log(dir, maxCount = 20) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
return await git.log({ maxCount }); const result = await git.log({ maxCount });
return {
latest: result?.latest || null,
all: (result?.all || []).map((entry) => ({
hash: entry.hash,
message: entry.message,
author: entry.author_name,
date: entry.date,
})),
};
} catch (err) {
return { all: [], error: err.message };
}
}
async function diff(dir, filePath) {
try {
const git = getGitInstance(dir);
const args = filePath ? ['--', filePath] : [];
return await git.diff(args);
} catch (err) { } catch (err) {
return { error: err.message }; return { error: err.message };
} }
} }
module.exports = { getStatus, stage, commit, log }; module.exports = { getStatus, stage, commit, log, diff };
+62 -43
View File
@@ -4,11 +4,11 @@ const { PDFDocument, rgb, degrees, StandardFonts } = require('pdf-lib');
function parsePageRanges(rangeString, totalPages) { function parsePageRanges(rangeString, totalPages) {
const pages = []; const pages = [];
const ranges = rangeString.split(',').map(r => r.trim()); const ranges = rangeString.split(',').map((r) => r.trim());
for (const range of ranges) { for (const range of ranges) {
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let i = start; i <= end && i <= totalPages; i++) { for (let i = start; i <= end && i <= totalPages; i++) {
if (i > 0 && !pages.includes(i - 1)) { if (i > 0 && !pages.includes(i - 1)) {
pages.push(i - 1); pages.push(i - 1);
@@ -27,11 +27,13 @@ function parsePageRanges(rangeString, totalPages) {
function hexToRgb(hex) { function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? { return result
r: parseInt(result[1], 16) / 255, ? {
g: parseInt(result[2], 16) / 255, r: parseInt(result[1], 16) / 255,
b: parseInt(result[3], 16) / 255 g: parseInt(result[2], 16) / 255,
} : { r: 0, g: 0, b: 0 }; b: parseInt(result[3], 16) / 255,
}
: { r: 0, g: 0, b: 0 };
} }
async function pdfMerge(data) { async function pdfMerge(data) {
@@ -42,7 +44,7 @@ async function pdfMerge(data) {
const pdfBytes = fs.readFileSync(filePath); const pdfBytes = fs.readFileSync(filePath);
const pdf = await PDFDocument.load(pdfBytes); const pdf = await PDFDocument.load(pdfBytes);
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach(page => mergedPdf.addPage(page)); copiedPages.forEach((page) => mergedPdf.addPage(page));
} }
const pdfBytes = await mergedPdf.save(); const pdfBytes = await mergedPdf.save();
@@ -63,13 +65,13 @@ async function pdfSplit(data) {
const splits = []; const splits = [];
if (data.splitMode === 'pages') { if (data.splitMode === 'pages') {
const ranges = data.pageRanges.split(',').map(r => r.trim()); const ranges = data.pageRanges.split(',').map((r) => r.trim());
for (let i = 0; i < ranges.length; i++) { for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]; const range = ranges[i];
const pages = []; const pages = [];
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map((n) => parseInt(n.trim()));
for (let p = start; p <= end && p <= totalPages; p++) { for (let p = start; p <= end && p <= totalPages; p++) {
pages.push(p - 1); pages.push(p - 1);
} }
@@ -108,7 +110,7 @@ async function pdfSplit(data) {
for (const split of splits) { for (const split of splits) {
const newPdf = await PDFDocument.create(); const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, split.pages); const copiedPages = await newPdf.copyPages(pdf, split.pages);
copiedPages.forEach(page => newPdf.addPage(page)); copiedPages.forEach((page) => newPdf.addPage(page));
const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`); const outputPath = path.join(data.outputFolder, `${baseName}_${split.name}.pdf`);
const newPdfBytes = await newPdf.save(); const newPdfBytes = await newPdf.save();
@@ -129,18 +131,18 @@ async function pdfCompress(data) {
const compressedPdfBytes = await pdf.save({ const compressedPdfBytes = await pdf.save({
useObjectStreams: true, useObjectStreams: true,
addDefaultPage: false, addDefaultPage: false,
objectsPerTick: 50 objectsPerTick: 50,
}); });
fs.writeFileSync(data.outputPath, compressedPdfBytes); fs.writeFileSync(data.outputPath, compressedPdfBytes);
const originalSize = fs.statSync(data.inputPath).size; const originalSize = fs.statSync(data.inputPath).size;
const compressedSize = fs.statSync(data.outputPath).size; const compressedSize = fs.statSync(data.outputPath).size;
const savings = ((originalSize - compressedSize) / originalSize * 100).toFixed(1); const savings = (((originalSize - compressedSize) / originalSize) * 100).toFixed(1);
return { return {
success: true, success: true,
message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)` message: `PDF compressed. Size reduced by ${savings}% (${(originalSize / 1024).toFixed(1)}KB → ${(compressedSize / 1024).toFixed(1)}KB)`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -160,7 +162,7 @@ async function pdfRotate(data) {
pagesToRotate = Array.from({ length: totalPages }, (_, i) => i); pagesToRotate = Array.from({ length: totalPages }, (_, i) => i);
} }
pagesToRotate.forEach(pageIndex => { pagesToRotate.forEach((pageIndex) => {
const page = pdf.getPage(pageIndex); const page = pdf.getPage(pageIndex);
page.setRotation(degrees(data.angle)); page.setRotation(degrees(data.angle));
}); });
@@ -170,7 +172,7 @@ async function pdfRotate(data) {
return { return {
success: true, success: true,
message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0` message: `Successfully rotated ${pagesToRotate.length} page(s) by ${data.angle}\u00B0`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -185,16 +187,18 @@ async function pdfDeletePages(data) {
const pagesToDelete = parsePageRanges(data.pages, totalPages); const pagesToDelete = parsePageRanges(data.pages, totalPages);
pagesToDelete.sort((a, b) => b - a).forEach(pageIndex => { pagesToDelete
pdf.removePage(pageIndex); .sort((a, b) => b - a)
}); .forEach((pageIndex) => {
pdf.removePage(pageIndex);
});
const newPdfBytes = await pdf.save(); const newPdfBytes = await pdf.save();
fs.writeFileSync(data.outputPath, newPdfBytes); fs.writeFileSync(data.outputPath, newPdfBytes);
return { return {
success: true, success: true,
message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages` message: `Successfully deleted ${pagesToDelete.length} page(s). New PDF has ${totalPages - pagesToDelete.length} pages`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -207,7 +211,7 @@ async function pdfReorder(data) {
const pdf = await PDFDocument.load(pdfBytes); const pdf = await PDFDocument.load(pdfBytes);
const totalPages = pdf.getPageCount(); const totalPages = pdf.getPageCount();
const newOrder = data.newOrder.split(',').map(n => parseInt(n.trim()) - 1); const newOrder = data.newOrder.split(',').map((n) => parseInt(n.trim()) - 1);
if (newOrder.length !== totalPages) { if (newOrder.length !== totalPages) {
return { success: false, error: `New order must include all ${totalPages} pages` }; return { success: false, error: `New order must include all ${totalPages} pages` };
@@ -215,7 +219,7 @@ async function pdfReorder(data) {
const newPdf = await PDFDocument.create(); const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(pdf, newOrder); const copiedPages = await newPdf.copyPages(pdf, newOrder);
copiedPages.forEach(page => newPdf.addPage(page)); copiedPages.forEach((page) => newPdf.addPage(page));
const reorderedPdfBytes = await newPdf.save(); const reorderedPdfBytes = await newPdf.save();
fs.writeFileSync(data.outputPath, reorderedPdfBytes); fs.writeFileSync(data.outputPath, reorderedPdfBytes);
@@ -246,7 +250,9 @@ async function pdfWatermark(data) {
const page = pdf.getPage(pageIndex); const page = pdf.getPage(pageIndex);
const { width, height } = page.getSize(); const { width, height } = page.getSize();
let x, y, rotation = 0; let x,
y,
rotation = 0;
switch (data.position) { switch (data.position) {
case 'center': case 'center':
@@ -294,7 +300,7 @@ async function pdfWatermark(data) {
font, font,
color: rgb(color.r, color.g, color.b), color: rgb(color.r, color.g, color.b),
opacity: data.opacity, opacity: data.opacity,
rotate: degrees(rotation) rotate: degrees(rotation),
}); });
} }
@@ -303,7 +309,7 @@ async function pdfWatermark(data) {
return { return {
success: true, success: true,
message: `Successfully added watermark to ${pagesToWatermark.length} page(s)` message: `Successfully added watermark to ${pagesToWatermark.length} page(s)`,
}; };
} catch (error) { } catch (error) {
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -325,8 +331,8 @@ async function pdfEncrypt(data) {
annotating: data.permissions.annotating, annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms, fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility, contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly documentAssembly: data.permissions.documentAssembly,
} },
}); });
fs.writeFileSync(data.outputPath, encryptedPdfBytes); fs.writeFileSync(data.outputPath, encryptedPdfBytes);
@@ -336,7 +342,8 @@ async function pdfEncrypt(data) {
if (error.message.includes('encrypt') || error.message.includes('password')) { if (error.message.includes('encrypt') || error.message.includes('password')) {
return { return {
success: false, success: false,
error: 'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.' error:
'PDF encryption requires pdf-lib with encryption support. This feature may not be available in the current version.',
}; };
} }
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -375,8 +382,8 @@ async function pdfSetPermissions(data) {
annotating: data.permissions.annotating, annotating: data.permissions.annotating,
fillingForms: data.permissions.fillingForms, fillingForms: data.permissions.fillingForms,
contentAccessibility: data.permissions.contentAccessibility, contentAccessibility: data.permissions.contentAccessibility,
documentAssembly: data.permissions.documentAssembly documentAssembly: data.permissions.documentAssembly,
} },
}); });
fs.writeFileSync(data.outputPath, newPdfBytes); fs.writeFileSync(data.outputPath, newPdfBytes);
@@ -386,7 +393,8 @@ async function pdfSetPermissions(data) {
if (error.message.includes('encrypt') || error.message.includes('permission')) { if (error.message.includes('encrypt') || error.message.includes('permission')) {
return { return {
success: false, success: false,
error: 'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.' error:
'PDF permissions require pdf-lib with encryption support. This feature may not be available in the current version.',
}; };
} }
return { success: false, error: error.message }; return { success: false, error: error.message };
@@ -395,17 +403,28 @@ async function pdfSetPermissions(data) {
function executeOperation(operation, data) { function executeOperation(operation, data) {
switch (operation) { switch (operation) {
case 'merge': return pdfMerge(data); case 'merge':
case 'split': return pdfSplit(data); return pdfMerge(data);
case 'compress': return pdfCompress(data); case 'split':
case 'rotate': return pdfRotate(data); return pdfSplit(data);
case 'delete': return pdfDeletePages(data); case 'compress':
case 'reorder': return pdfReorder(data); return pdfCompress(data);
case 'watermark': return pdfWatermark(data); case 'rotate':
case 'encrypt': return pdfEncrypt(data); return pdfRotate(data);
case 'decrypt': return pdfDecrypt(data); case 'delete':
case 'permissions': return pdfSetPermissions(data); return pdfDeletePages(data);
default: return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` }); case 'reorder':
return pdfReorder(data);
case 'watermark':
return pdfWatermark(data);
case 'encrypt':
return pdfEncrypt(data);
case 'decrypt':
return pdfDecrypt(data);
case 'permissions':
return pdfSetPermissions(data);
default:
return Promise.resolve({ success: false, error: `Unknown operation: ${operation}` });
} }
} }
@@ -429,5 +448,5 @@ module.exports = {
pdfDecrypt, pdfDecrypt,
pdfSetPermissions, pdfSetPermissions,
executeOperation, executeOperation,
getPageCount getPageCount,
}; };
+18
View File
@@ -0,0 +1,18 @@
// src/main/files/binary.js
// Binary file write handler (used by Word .docx export)
const { ipcMain } = require('electron');
const fs = require('fs').promises;
function register() {
ipcMain.handle('write-buffer', async (_event, { path: filePath, buffer }) => {
await fs.writeFile(filePath, Buffer.from(buffer));
return { ok: true };
});
ipcMain.handle('read-buffer', async (_event, { path: filePath }) => {
const data = await fs.readFile(filePath);
return { ok: true, data };
});
}
module.exports = { register };
+45
View File
@@ -0,0 +1,45 @@
// src/main/files/git.js
// Git IPC handlers — thin wrapper over GitOperations
const { ipcMain } = require('electron');
const GitOperations = require('../GitOperations');
function register(currentFileRef) {
ipcMain.handle('git-status', async (_event, rootPath) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.getStatus(dir);
});
ipcMain.handle('git-stage', async (_event, { rootPath, files }) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.stage(dir, files);
});
ipcMain.handle('git-commit', async (_event, { rootPath, message }) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.commit(dir, message);
});
ipcMain.handle('git-log', async (_event, rootPath) => {
const dir =
rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.log(dir);
});
ipcMain.handle('git-diff', async (_event, filePath) => {
const dir = filePath
? require('path').dirname(filePath)
: currentFileRef.current
? require('path').dirname(currentFileRef.current)
: process.cwd();
return GitOperations.diff(dir, filePath);
});
}
module.exports = { register };
+163
View File
@@ -0,0 +1,163 @@
// src/main/files/index.js
// File ops facade — registers all file-related IPC handlers
const { ipcMain, dialog } = require('electron');
const fs = require('fs');
const path = require('path');
const { register: registerGit } = require('./git');
const { register: registerBinary } = require('./binary');
function register({
validatePath,
resolveWritablePath,
isPathAccessible,
currentFileRef,
mainWindow,
}) {
// pick-folder
ipcMain.handle('pick-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// pick-file
ipcMain.handle('pick-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
// read-file
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');
});
// write-file
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 };
});
// delete-file
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;
});
// ensure-directory
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;
});
// path-exists
ipcMain.handle('path-exists', async (event, filePath) => {
const validation = resolveWritablePath(filePath);
return validation.valid ? fs.existsSync(validation.resolved) : false;
});
// is-directory
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();
});
// copy-path
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 };
});
// move-path
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-file-path
ipcMain.on('open-file-path', (event, filePath) => {
try {
const validation = validatePath(filePath);
if (!validation.valid) {
console.error('[SECURITY] Invalid file path:', validation.error);
return;
}
if (!isPathAccessible(validation.resolved)) {
return;
}
const stat = fs.statSync(validation.resolved);
if (stat.size > 50 * 1024 * 1024) return;
const content = fs.readFileSync(validation.resolved, 'utf-8');
// Emit set-current-file back to main process so it updates its currentFile variable
mainWindow.webContents.send('set-current-file', validation.resolved);
mainWindow.webContents.send('file-opened', { path: validation.resolved, content });
} catch (err) {
console.error('open-file-path error:', err);
}
});
// Register sub-modules
registerGit(currentFileRef);
registerBinary();
}
module.exports = { register };
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
const { ipcMain, shell } = require('electron');
function register({ crash, getMainWindow: _getMainWindow }) {
ipcMain.handle('crash:read', () => {
return crash.list();
});
ipcMain.on('crash:open-dir', () => {
shell.openPath(crash.path());
});
ipcMain.handle('crash:delete', (_event, filename) => {
if (
typeof filename === 'string' &&
/^\d+(-\d+)?-(uncaughtException|unhandledRejection)\.json$/.test(filename)
) {
crash.delete(filename);
return true;
}
return false;
});
}
module.exports = { register };
+29
View File
@@ -0,0 +1,29 @@
const { ipcMain } = require('electron');
const { feedConfigFor } = require('../updater/feed-config');
function register({ updater, getMainWindow, getChannel }) {
ipcMain.handle('updater:check', async () => {
const channel = getChannel();
updater.autoUpdater.setFeedURL(feedConfigFor(channel));
await updater.check();
return { state: updater.state };
});
ipcMain.handle('updater:install', () => {
updater.install();
});
ipcMain.handle('updater:get-state', () => {
return { state: updater.state };
});
// Forward status events to renderer
updater.on('status', (payload) => {
const win = getMainWindow();
if (win && !win.isDestroyed()) {
win.webContents.send('updater:status', payload);
}
});
}
module.exports = { register };
+34
View File
@@ -0,0 +1,34 @@
// src/main/menu/index.js
// buildMenu() composes the app menu from items; register() sets it on the app
const { Menu } = require('electron');
const {
fileItems,
editItems,
viewItems,
batchItems,
convertItems,
pdfEditorItems,
toolsItems,
helpItems,
} = require('./items');
function buildMenu(mainWindow) {
const template = [
{ label: '&File', submenu: fileItems(mainWindow) },
{ label: '&Edit', submenu: editItems(mainWindow) },
{ label: '&View', submenu: viewItems(mainWindow) },
{ label: '&Batch', submenu: batchItems(mainWindow) },
{ label: '&Convert', submenu: convertItems(mainWindow) },
{ label: 'PDF Editor', submenu: pdfEditorItems(mainWindow) },
{ label: '&Tools', submenu: toolsItems(mainWindow) },
{ label: '&Help', submenu: helpItems(mainWindow) },
];
return Menu.buildFromTemplate(template);
}
function register(mainWindow) {
Menu.setApplicationMenu(buildMenu(mainWindow));
}
module.exports = { register, buildMenu };
+799
View File
@@ -0,0 +1,799 @@
// src/main/menu/items.js
// Individual menu items — pure functions that take (mainWindow) and return menu item arrays
const { app, shell } = require('electron');
const path = require('path');
const fs = require('fs');
// Helper: build recent files submenu
function buildRecentFilesMenu(mainWindow) {
try {
const recentFilesPath = path.join(app.getPath('userData'), 'recent-files.json');
if (!fs.existsSync(recentFilesPath)) return [{ label: 'No Recent Files', enabled: false }];
const recentFiles = JSON.parse(fs.readFileSync(recentFilesPath, 'utf-8'));
const existing = recentFiles.filter((file) => fs.existsSync(file));
if (existing.length === 0) return [{ label: 'No Recent Files', enabled: false }];
const items = existing.map((file) => ({
label: path.basename(file),
click: () => {
const { openFileFromPath } = require('../index');
openFileFromPath(file);
},
}));
items.push(
{ type: 'separator' },
{
label: 'Clear Recent Files',
click: () => {
if (mainWindow) {
mainWindow.webContents.send('clear-recent-files');
}
},
}
);
return items;
} catch (_e) {
return [{ label: 'No Recent Files', enabled: false }];
}
}
function fileItems(mainWindow) {
return [
{
label: 'New',
accelerator: 'CmdOrCtrl+N',
click: () => mainWindow.webContents.send('file-new'),
},
{
label: 'Open',
accelerator: 'CmdOrCtrl+O',
click: () => {
const { openFile } = require('../index');
openFile();
},
},
{
label: 'Open PDF',
accelerator: 'CmdOrCtrl+Shift+O',
click: () => {
const { openPdfFile } = require('../index');
openPdfFile();
},
},
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => mainWindow.webContents.send('file-save'),
},
{
label: 'Save As',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => {
const { saveAsFile } = require('../index');
saveAsFile();
},
},
{ type: 'separator' },
// NOTE: Print Preview submenu removed — handled by React <PrintPreview> overlay
{ type: 'separator' },
{
label: 'Recent Files',
submenu: buildRecentFilesMenu(mainWindow),
},
{ type: 'separator' },
{
label: 'New from Template',
submenu: [
{
label: 'Blog Post',
click: () => mainWindow.webContents.send('load-template-menu', 'blog-post.md'),
},
{
label: 'Meeting Notes',
click: () => mainWindow.webContents.send('load-template-menu', 'meeting-notes.md'),
},
{
label: 'Technical Spec',
click: () => mainWindow.webContents.send('load-template-menu', 'technical-spec.md'),
},
{
label: 'Changelog',
click: () => mainWindow.webContents.send('load-template-menu', 'changelog.md'),
},
{
label: 'README',
click: () => mainWindow.webContents.send('load-template-menu', 'readme.md'),
},
{
label: 'Project Plan',
click: () => mainWindow.webContents.send('load-template-menu', 'project-plan.md'),
},
{
label: 'API Documentation',
click: () => mainWindow.webContents.send('load-template-menu', 'api-docs.md'),
},
{
label: 'Tutorial',
click: () => mainWindow.webContents.send('load-template-menu', 'tutorial.md'),
},
{
label: 'Release Notes',
click: () => mainWindow.webContents.send('load-template-menu', 'release-notes.md'),
},
{
label: 'Comparison',
click: () => mainWindow.webContents.send('load-template-menu', 'comparison.md'),
},
],
},
{ type: 'separator' },
{
label: 'Import Document...',
accelerator: 'CmdOrCtrl+I',
click: () => {
const { importDocument } = require('../index');
importDocument();
},
},
{
label: 'Export',
submenu: [
{
label: 'HTML',
click: () => {
const { exportFile } = require('../index');
exportFile('html');
},
},
{
label: 'PDF',
click: () => {
const { exportFile } = require('../index');
exportFile('pdf');
},
},
{
label: 'PDF (Enhanced)',
click: () => {
const { exportPDFViaWordTemplate } = require('../index');
exportPDFViaWordTemplate();
},
accelerator: 'Ctrl+Shift+P',
},
{
label: 'DOCX',
click: () => {
const { exportFile } = require('../index');
exportFile('docx');
},
},
{
label: 'DOCX (Enhanced)',
click: () => {
const { exportWordWithTemplate } = require('../index');
exportWordWithTemplate();
},
accelerator: 'Ctrl+Shift+W',
},
{
label: 'LaTeX',
click: () => {
const { exportFile } = require('../index');
exportFile('latex');
},
},
{
label: 'RTF',
click: () => {
const { exportFile } = require('../index');
exportFile('rtf');
},
},
{
label: 'ODT',
click: () => {
const { exportFile } = require('../index');
exportFile('odt');
},
},
{
label: 'EPUB',
click: () => {
const { exportFile } = require('../index');
exportFile('epub');
},
},
{ type: 'separator' },
{
label: 'PowerPoint (PPTX)',
click: () => {
const { exportFile } = require('../index');
exportFile('pptx');
},
},
{
label: 'OpenDocument Presentation (ODP)',
click: () => {
const { exportFile } = require('../index');
exportFile('odp');
},
},
{ type: 'separator' },
{
label: 'CSV (Tables)',
click: () => {
const { exportSpreadsheet } = require('../index');
exportSpreadsheet('csv');
},
},
{ type: 'separator' },
{
label: 'JSON (.json)',
click: () => {
const { exportFile } = require('../index');
exportFile('json');
},
},
{
label: 'YAML (.yaml)',
click: () => {
const { exportFile } = require('../index');
exportFile('yaml');
},
},
{
label: 'XML (.xml)',
click: () => {
const { exportFile } = require('../index');
exportFile('xml');
},
},
{ type: 'separator' },
{
label: 'Confluence Wiki (.txt)',
click: () => {
const { exportFile } = require('../index');
exportFile('confluence');
},
},
{
label: 'MOBI E-book (.mobi)',
click: () => {
const { exportFile } = require('../index');
exportFile('mobi');
},
},
],
},
{ type: 'separator' },
{
label: 'Select Word Template...',
click: () => {
const { selectWordTemplate } = require('../index');
selectWordTemplate();
},
},
{
label: 'Template Settings...',
click: () => {
const { showTemplateSettings } = require('../index');
showTemplateSettings();
},
},
{
label: 'Header & Footer Settings...',
click: () => {
if (mainWindow) {
mainWindow.webContents.send('open-header-footer-dialog');
}
},
},
{ type: 'separator' },
{
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => app.quit(),
},
];
}
function editItems(mainWindow) {
return [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
click: () => mainWindow.webContents.send('undo'),
},
{
label: 'Redo',
accelerator: 'CmdOrCtrl+Shift+Z',
click: () => mainWindow.webContents.send('redo'),
},
{ type: 'separator' },
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
{ type: 'separator' },
{
label: 'Find & Replace',
accelerator: 'CmdOrCtrl+F',
click: () => mainWindow.webContents.send('toggle-find'),
},
];
}
function viewItems(mainWindow) {
return [
{
label: 'Toggle Preview',
accelerator: 'CmdOrCtrl+Shift+V',
click: () => mainWindow.webContents.send('toggle-preview'),
},
{
label: 'Writing Analytics',
accelerator: 'CmdOrCtrl+Shift+A',
click: () => mainWindow.webContents.send('show-analytics-dialog'),
},
// NOTE: Command Palette removed — handled by useCommandStore
{ type: 'separator' },
{
label: 'Sidebar',
submenu: [
{
label: 'Files',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'explorer'),
},
{
label: 'Outline',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'outline'),
},
{
label: 'Snippets',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'snippets'),
},
{
label: 'Templates',
click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'templates'),
},
{ label: 'Git', click: () => mainWindow.webContents.send('toggle-sidebar-panel', 'git') },
],
},
{
label: 'Bottom Panel (REPL)',
click: () => mainWindow.webContents.send('toggle-bottom-panel'),
},
{ type: 'separator' },
{
label: 'Theme',
submenu: [
{
label: 'Atom One Light (Default)',
click: () => {
const { setTheme } = require('../index');
setTheme('atomonelight');
},
},
{
label: 'GitHub Light',
click: () => {
const { setTheme } = require('../index');
setTheme('github');
},
},
{
label: 'Light',
click: () => {
const { setTheme } = require('../index');
setTheme('light');
},
},
{
label: 'Solarized Light',
click: () => {
const { setTheme } = require('../index');
setTheme('solarized');
},
},
{
label: 'Gruvbox Light',
click: () => {
const { setTheme } = require('../index');
setTheme('gruvbox-light');
},
},
{
label: 'Ayu Light',
click: () => {
const { setTheme } = require('../index');
setTheme('ayu-light');
},
},
{
label: 'Sepia',
click: () => {
const { setTheme } = require('../index');
setTheme('sepia');
},
},
{
label: 'Paper',
click: () => {
const { setTheme } = require('../index');
setTheme('paper');
},
},
{
label: 'Rose Pine Dawn',
click: () => {
const { setTheme } = require('../index');
setTheme('rosepine-dawn');
},
},
{
label: 'Concrete Light',
click: () => {
const { setTheme } = require('../index');
setTheme('concrete-light');
},
},
{ type: 'separator' },
{
label: 'Dark',
click: () => {
const { setTheme } = require('../index');
setTheme('dark');
},
},
{
label: 'One Dark',
click: () => {
const { setTheme } = require('../index');
setTheme('onedark');
},
},
{
label: 'Dracula',
click: () => {
const { setTheme } = require('../index');
setTheme('dracula');
},
},
{
label: 'Nord',
click: () => {
const { setTheme } = require('../index');
setTheme('nord');
},
},
{
label: 'Monokai',
click: () => {
const { setTheme } = require('../index');
setTheme('monokai');
},
},
{
label: 'Material',
click: () => {
const { setTheme } = require('../index');
setTheme('material');
},
},
{
label: 'Gruvbox Dark',
click: () => {
const { setTheme } = require('../index');
setTheme('gruvbox-dark');
},
},
{
label: 'Tokyo Night',
click: () => {
const { setTheme } = require('../index');
setTheme('tokyonight');
},
},
{
label: 'Palenight',
click: () => {
const { setTheme } = require('../index');
setTheme('palenight');
},
},
{
label: 'Ayu Dark',
click: () => {
const { setTheme } = require('../index');
setTheme('ayu-dark');
},
},
{
label: 'Ayu Mirage',
click: () => {
const { setTheme } = require('../index');
setTheme('ayu-mirage');
},
},
{
label: 'Oceanic Next',
click: () => {
const { setTheme } = require('../index');
setTheme('oceanic-next');
},
},
{
label: 'Cobalt2',
click: () => {
const { setTheme } = require('../index');
setTheme('cobalt2');
},
},
{
label: 'Concrete Dark',
click: () => {
const { setTheme } = require('../index');
setTheme('concrete-dark');
},
},
{
label: 'Concrete Warm',
click: () => {
const { setTheme } = require('../index');
setTheme('concrete-warm');
},
},
],
},
{ type: 'separator' },
{
label: 'Font Size',
submenu: [
{
label: 'Increase Font Size',
accelerator: 'CmdOrCtrl+Shift+Plus',
click: () => mainWindow.webContents.send('adjust-font-size', 'increase'),
},
{
label: 'Decrease Font Size',
accelerator: 'CmdOrCtrl+Shift+-',
click: () => mainWindow.webContents.send('adjust-font-size', 'decrease'),
},
{
label: 'Reset Font Size',
accelerator: 'CmdOrCtrl+Shift+0',
click: () => mainWindow.webContents.send('adjust-font-size', 'reset'),
},
],
},
{ type: 'separator' },
{
label: 'Spell Check',
type: 'checkbox',
checked: true,
click: (menuItem) => {
mainWindow.webContents.session.setSpellCheckerEnabled(menuItem.checked);
},
},
{ type: 'separator' },
{
label: 'Custom Preview CSS',
submenu: [
{
label: 'Load Custom Preview CSS...',
click: () => mainWindow.webContents.send('load-custom-css'),
},
{
label: 'Clear Custom Preview CSS',
click: () => mainWindow.webContents.send('clear-custom-css'),
},
],
},
{ type: 'separator' },
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
{ label: 'Toggle DevTools', accelerator: 'F12', role: 'toggleDevTools' },
{ type: 'separator' },
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', role: 'resetZoom' },
];
}
function batchItems(mainWindow) {
return [
{
label: 'Convert Markdown Folder...',
click: () => {
const { showBatchConversionDialog } = require('../index');
showBatchConversionDialog();
},
},
{ type: 'separator' },
{
label: 'Batch Image Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'image'),
},
{
label: 'Batch Audio Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'audio'),
},
{
label: 'Batch Video Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'video'),
},
{
label: 'Batch PDF Conversion...',
click: () => mainWindow.webContents.send('show-batch-converter', 'pdf'),
},
];
}
function convertItems(_mainWindow) {
return [
{
label: 'Universal File Converter...',
accelerator: 'CmdOrCtrl+Shift+C',
click: () => {
const { showUniversalConverterDialog } = require('../index');
showUniversalConverterDialog();
},
},
];
}
function pdfEditorItems(_mainWindow) {
return [
{
label: 'Open PDF File...',
accelerator: 'CmdOrCtrl+Shift+O',
click: () => {
const { openPdfFile } = require('../index');
openPdfFile();
},
},
{ type: 'separator' },
{
label: 'Merge PDFs...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('merge');
},
},
{
label: 'Split PDF...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('split');
},
},
{
label: 'Compress PDF...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('compress');
},
},
{ type: 'separator' },
{
label: 'Rotate Pages...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('rotate');
},
},
{
label: 'Delete Pages...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('delete');
},
},
{
label: 'Reorder Pages...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('reorder');
},
},
{ type: 'separator' },
{
label: 'Add Watermark...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('watermark');
},
},
{ type: 'separator' },
{
label: 'Security',
submenu: [
{
label: 'Add Password Protection...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('encrypt');
},
},
{
label: 'Remove Password...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('decrypt');
},
},
{
label: 'Set Permissions...',
click: () => {
const { showPDFEditorDialog } = require('../index');
showPDFEditorDialog('permissions');
},
},
],
},
{ type: 'separator' },
{
label: 'About PDF Editor',
click: () => {
const { showAboutDialog } = require('../index');
showAboutDialog();
},
},
];
}
function toolsItems(mainWindow) {
// NOTE: Table Generator and ASCII Art Generator removed — handled by React dialogs
return [
// Removed: Table Generator (Cmd+Ctrl+Shift+T) — now React <TableGeneratorDialog>
// Removed: ASCII Art Generator (Cmd+Ctrl+Shift+A) — now React <AsciiGeneratorDialog>
{ type: 'separator' },
{
label: 'Document Compare',
click: () => mainWindow.webContents.send('show-document-compare'),
},
];
}
function helpItems(_mainWindow) {
return [
{
label: 'About MarkdownConverter',
click: () => {
const { showAboutDialog } = require('../index');
showAboutDialog();
},
},
{ type: 'separator' },
{
label: 'Dependencies & Requirements',
click: () => {
const { showDependenciesDialog } = require('../index');
showDependenciesDialog();
},
},
{ type: 'separator' },
{
label: 'Documentation',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter'),
},
{
label: 'Report Issue',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'),
},
{
label: 'Check for Updates',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases'),
},
];
}
module.exports = {
fileItems,
editItems,
viewItems,
batchItems,
convertItems,
pdfEditorItems,
toolsItems,
helpItems,
};
+31
View File
@@ -0,0 +1,31 @@
// src/main/store.js
// Simple JSON-file preferences store (replaces electron-store)
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
const store = {
get(key, defaultValue) {
try {
const data = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(data);
return settings[key] !== undefined ? settings[key] : defaultValue;
} catch {
return defaultValue;
}
},
set(key, value) {
let settings = {};
try {
const data = fs.readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(data);
} catch {}
settings[key] = value;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
},
};
module.exports = store;
+64
View File
@@ -0,0 +1,64 @@
const fs = require('fs');
const path = require('path');
const MAX_DUMPS = 20;
class CrashWriter {
constructor(dir) {
this.dir = dir;
this._counter = 0;
fs.mkdirSync(dir, { recursive: true });
}
handleUncaught(err, kind) {
try {
const filename = `${Date.now()}-${++this._counter}-${kind}.json`;
const payload = {
kind,
message: err && err.message,
stack: err && err.stack,
timestamp: new Date().toISOString(),
};
fs.writeFileSync(path.join(this.dir, filename), JSON.stringify(payload, null, 2));
this._prune();
} catch (writeErr) {
console.error('[crash-writer] dump write failed:', writeErr.message);
}
}
_prune() {
const files = fs.readdirSync(this.dir).sort();
while (files.length > MAX_DUMPS) {
const oldest = files.shift();
try {
fs.unlinkSync(path.join(this.dir, oldest));
} catch (_unlinkErr) {
/* ignore */
}
}
}
list() {
if (!fs.existsSync(this.dir)) return [];
return fs
.readdirSync(this.dir)
.filter((f) => f.endsWith('.json'))
.sort()
.reverse()
.map((filename) => {
const full = path.join(this.dir, filename);
const data = JSON.parse(fs.readFileSync(full, 'utf-8'));
return { filename, ...data };
});
}
delete(filename) {
const full = path.join(this.dir, filename);
if (fs.existsSync(full)) fs.unlinkSync(full);
}
path() {
return this.dir;
}
}
module.exports = { CrashWriter };
+8
View File
@@ -0,0 +1,8 @@
function feedConfigFor(channel) {
if (channel === 'github') {
return { provider: 'github', owner: 'amitwh', repo: 'markdown-converter' };
}
return { provider: 'generic', url: 'https://updates.concreteinfo.co.in/v5' };
}
module.exports = { feedConfigFor };
+42
View File
@@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
class MigrationRunner {
constructor({ dir, transform }) {
this.dir = dir;
this.transform = transform;
this.file = path.join(dir, 'settings.json');
this.backup = path.join(dir, 'settings.v4.bak.json');
}
run() {
if (!fs.existsSync(this.file)) {
this._writeDefaults();
return 'fresh';
}
const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
if (raw && raw['migration.version'] === 5) {
return 'skipped';
}
try {
const v5 = this.transform(raw);
fs.copyFileSync(this.file, this.backup);
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
return 'migrated';
} catch (err) {
console.error('[migration-runner] transform failed:', err.message);
// Back up the original and write v5 marker so future launches skip migration.
// Without this, every launch would fail again and the user stays on defaults.
fs.copyFileSync(this.file, this.backup);
fs.writeFileSync(this.file, JSON.stringify({ ...raw, 'migration.version': 5 }, null, 2));
return 'failed';
}
}
_writeDefaults() {
const v5 = this.transform({});
fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2));
}
}
module.exports = { MigrationRunner };
+117
View File
@@ -0,0 +1,117 @@
// Mirror of src/renderer/lib/migrations/v4-to-v5.ts, plain JS, for main-process use.
// Kept in sync manually; if the renderer transform changes, update this too.
const { z } = require('zod');
const v4SettingsSchema = z
.object({
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
customCss: z.string().optional().nullable(),
recentFiles: z.array(z.string()).default([]),
editorFontSize: z.number().min(10).max(28).default(14),
keyBindings: z.record(z.string(), z.string()).optional(),
snippets: z.array(z.unknown()).default([]),
})
.passthrough();
const v5SettingsSchema = z
.object({
fontSize: z.number().default(14),
tabSize: z.number().default(4),
lineNumbers: z.boolean().default(true),
wordWrap: z.boolean().default(true),
minimap: z.boolean().default(true),
theme: z.enum(['light', 'dark', 'system']).default('system'),
accentColor: z.string().default('brand'),
fontFamily: z.string().default('system'),
pdfFormat: z.string().default('a4'),
pdfMargins: z.string().default('normal'),
pdfEmbedFonts: z.boolean().default(true),
docxTemplate: z.string().default('standard'),
docxCustomTemplatePath: z.string().nullable().default(null),
replOpen: z.boolean().default(false),
breadcrumbSymbols: z.boolean().default(true),
htmlHighlightStyle: z.string().default('github'),
renderTablesAsAscii: z.boolean().default(false),
welcomeDismissed: z.boolean().default(false),
editorFontSize: z.number().default(14),
customCssPath: z.string().nullable().default(null),
userBindings: z.record(z.string(), z.string()).default({}),
updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
autoCheckUpdates: z.boolean().default(true),
firstRun: z.boolean().default(true),
'migration.version': z.literal(5).optional(),
})
.passthrough();
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun'];
const v5ThemeValues = ['light', 'dark', 'system'];
function isAlreadyV5(data) {
if (!data || typeof data !== 'object') return false;
if (data['migration.version'] === 5) return true;
// Check for v5-only fields — a v4 file would never have these
return v5OnlyFields.some((f) => f in data);
}
function normalizeAlreadyV5(data) {
// Some earlier v5 builds wrote a legacy theme value (e.g. "ayu-light")
// under the v5 marker. Trusting the marker blindly broke the renderer's
// zod schema on every launch. Always normalize theme against the v5 enum
// before returning, so persisted files are always valid v5.
const out = { ...data };
if (typeof out.theme !== 'string' || !v5ThemeValues.includes(out.theme)) {
out.theme = 'system';
}
return out;
}
const v5SettingsShape = {
fontSize: 14,
tabSize: 4,
lineNumbers: true,
wordWrap: true,
minimap: true,
theme: 'system',
accentColor: 'brand',
fontFamily: 'system',
pdfFormat: 'a4',
pdfMargins: 'normal',
pdfEmbedFonts: true,
docxTemplate: 'standard',
docxCustomTemplatePath: null,
replOpen: false,
breadcrumbSymbols: true,
htmlHighlightStyle: 'github',
renderTablesAsAscii: false,
welcomeDismissed: false,
editorFontSize: 14,
customCssPath: null,
userBindings: {},
updateChannel: 'github',
autoCheckUpdates: true,
firstRun: true,
};
function migrateV4ToV5(v4) {
// If data already looks like v5 (has migration.version=5 or v5-only fields),
// normalize and validate against the v5 schema. This handles the case where
// a buggy v5 run wrote v5 fields without the marker, AND the case where a
// v5 file has a legacy theme value (e.g. "ayu-light") that would otherwise
// be rejected by the renderer.
if (isAlreadyV5(v4)) {
return v5SettingsSchema.parse(normalizeAlreadyV5(v4));
}
const parsed = v4SettingsSchema.parse(v4 || {});
return {
...v5SettingsShape,
...parsed,
theme: parsed.theme === 'auto' ? 'system' : parsed.theme,
customCssPath: parsed.customCss ?? null,
recentFiles: parsed.recentFiles,
editorFontSize: parsed.editorFontSize,
userBindings: parsed.keyBindings ?? v5SettingsShape.userBindings,
snippets: parsed.snippets,
};
}
module.exports = { migrateV4ToV5, v5SettingsShape };
+43
View File
@@ -0,0 +1,43 @@
const { EventEmitter } = require('events');
const DEBOUNCE_MS = 60_000;
class UpdaterService extends EventEmitter {
constructor(autoUpdater) {
super();
this.autoUpdater = autoUpdater;
this.state = 'idle';
this.lastCheckAt = 0;
this._wire();
}
_wire() {
const au = this.autoUpdater;
au.on('checking-for-update', () => this._emit({ state: 'checking' }));
au.on('update-available', (info) => this._emit({ state: 'available', version: info.version }));
au.on('download-progress', (p) => this._emit({ state: 'downloading', percent: p.percent }));
au.on('update-downloaded', (info) => this._emit({ state: 'ready', version: info.version }));
au.on('update-not-available', () => this._emit({ state: 'idle' }));
au.on('error', (err) => {
const code =
err && /ENOTFOUND|ETIMEDOUT|ECONNREFUSED/.test(err.message) ? 'NETWORK' : 'UNKNOWN';
this._emit({ state: 'error', code });
});
}
_emit(payload) {
this.state = payload.state;
this.emit('status', payload);
}
async check() {
if (Date.now() - this.lastCheckAt < DEBOUNCE_MS) return;
this.lastCheckAt = Date.now();
await this.autoUpdater.checkForUpdates();
}
install() {
this.autoUpdater.quitAndInstall();
}
}
module.exports = { UpdaterService };
+111
View File
@@ -0,0 +1,111 @@
// src/main/utils/paths.js
// Path helpers — extracted from src/main.js lines 109-207
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
function getAllowedDirectories() {
const dirs = [
app.getPath('documents'),
app.getPath('desktop'),
app.getPath('downloads'),
app.getPath('home'),
process.cwd(), // Current working directory
].filter(Boolean); // Remove any undefined paths
return dirs;
}
/**
* Validates that a file path is safe and doesn't attempt path traversal
* @param {string} filePath - The path to validate
* @returns {{ valid: boolean, resolved: string, error?: string }}
*/
function validatePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, resolved: '', error: 'Invalid path' };
}
// Resolve to absolute path (handles .., ., symlinks)
let resolved;
try {
resolved = path.resolve(filePath);
} catch (_err) {
return { valid: false, resolved: '', error: 'Invalid path format' };
}
// Normalize path separators
resolved = path.normalize(resolved);
// Check for null bytes (path injection)
if (resolved.includes('\0')) {
return { valid: false, resolved: '', error: 'Null byte in path' };
}
// Check if path exists
if (!fs.existsSync(resolved)) {
return { valid: false, resolved, error: 'Path does not exist' };
}
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 (_err2) {
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
* For an editor app, we allow access to all user-accessible paths
* but log any suspicious access attempts
* @param {string} resolvedPath - The resolved absolute path
* @returns {boolean}
*/
function isPathAccessible(resolvedPath) {
// Block access to sensitive system directories
const blockedPaths = [
'/etc/passwd',
'/etc/shadow',
'/root',
'C:\\Windows\\System32',
'C:\\Windows\\System',
'/System',
'/private/etc',
];
const normalizedPath = resolvedPath.toLowerCase();
for (const blocked of blockedPaths) {
if (normalizedPath.startsWith(blocked.toLowerCase())) {
console.warn('[SECURITY] Blocked access to sensitive path:', resolvedPath);
return false;
}
}
return true;
}
module.exports = { getAllowedDirectories, validatePath, resolveWritablePath, isPathAccessible };
+126
View File
@@ -0,0 +1,126 @@
// src/main/window/index.js
// Main window creation
const { app, BrowserWindow } = require('electron');
const path = require('path');
const fs = require('fs');
const state = require('./state');
const menu = require('../menu');
function createMainWindow() {
const bounds = state.load();
const win = new BrowserWindow({
width: bounds.width,
height: bounds.height,
x: bounds.x,
y: bounds.y,
show: true,
webPreferences: {
// The preload script exposes `window.electronAPI` — the only IPC
// bridge the renderer uses. Without this, every renderer call returns
// CHANNEL_MISSING and file/folder/save all silently no-op.
preload: path.join(__dirname, '../../preload.js'),
// contextIsolation MUST be true for the preload's contextBridge
// exposeInMainWorld call to succeed. Without it, the preload throws
// on load and the renderer never gets the IPC bridge.
contextIsolation: true,
nodeIntegration: false,
spellcheck: true,
},
icon: path.join(__dirname, '../../../assets/icon.png'),
});
// Dev (Vite): load the running dev server so .tsx is transformed on the fly.
// Production (running from dist/ directly): load the built renderer at the
// relative path from this file.
// Production (packaged installer build): the renderer ships under
// process.resourcesPath/renderer (configured via build.extraResources in
// package.json) — not inside the asar.
// VITE_DEV_SERVER_URL is set by `npm run dev` via cross-env.
// app.isPackaged is set by electron-builder for installer builds.
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (!app.isPackaged && devServerUrl) {
console.log('[WINDOW] Dev mode — loading', devServerUrl);
win.loadURL(devServerUrl);
} else {
const rendererIndex = app.isPackaged
? path.join(process.resourcesPath, 'renderer', 'index.html')
: path.join(__dirname, '../../../dist/renderer/index.html');
if (app.isPackaged) {
try {
fs.accessSync(rendererIndex);
} catch {
console.error(
'[WINDOW] Renderer not found at',
rendererIndex,
'— did you run `npm run build:renderer` before packaging?'
);
}
}
console.log('[WINDOW] Production mode — loading', rendererIndex);
win.loadFile(rendererIndex);
}
// Show window only after content is ready — avoids blank flash
win.once('ready-to-show', () => {
win.show();
});
menu.register(win);
// Use 'close' (fires before destruction) — 'closed' fires after the
// BrowserWindow object is destroyed, so getBounds() would throw.
win.on('close', () => {
if (!win.isDestroyed()) state.save(win);
});
// Spell check context menu
win.webContents.on('context-menu', (event, params) => {
const { Menu, MenuItem } = require('electron');
const ctxMenu = new Menu();
// Add spell check suggestions
if (params.misspelledWord) {
for (const suggestion of params.dictionarySuggestions) {
ctxMenu.append(
new MenuItem({
label: suggestion,
click: () => win.webContents.replaceMisspelling(suggestion),
})
);
}
if (params.dictionarySuggestions.length > 0) {
ctxMenu.append(new MenuItem({ type: 'separator' }));
}
ctxMenu.append(
new MenuItem({
label: 'Add to Dictionary',
click: () =>
win.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
})
);
ctxMenu.append(new MenuItem({ type: 'separator' }));
}
// Standard context menu items
ctxMenu.append(new MenuItem({ role: 'cut' }));
ctxMenu.append(new MenuItem({ role: 'copy' }));
ctxMenu.append(new MenuItem({ role: 'paste' }));
ctxMenu.append(new MenuItem({ role: 'selectAll' }));
ctxMenu.popup();
});
// Wait for the page to fully load before sending file data
win.webContents.on('did-finish-load', () => {
console.log('Window finished loading');
// Don't open file here - wait for renderer-ready signal
// The renderer will send renderer-ready when TabManager is initialized
});
return win;
}
module.exports = { createMainWindow };
+27
View File
@@ -0,0 +1,27 @@
// src/main/window/state.js
// Window state persistence (size, position)
const { app } = require('electron');
const path = require('path');
const fs = require('fs');
const stateFile = path.join(app.getPath('userData'), 'window-state.json');
function load() {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return { width: 1200, height: 800 };
}
}
function save(win) {
const bounds = win.getBounds();
try {
fs.writeFileSync(stateFile, JSON.stringify(bounds));
} catch {
/* ignore */
}
}
module.exports = { load, save };
+807
View File
@@ -0,0 +1,807 @@
/**
* Word Template Exporter
* Loads word_template.docx, preserves first 2 pages (cover + TOC),
* and adds markdown content starting from page 3 using template styles
*/
const fs = require('fs');
const path = require('path');
const PizZip = require('pizzip');
class WordTemplateExporter {
constructor(templatePath, startPage = 3, pageSettings = null) {
this.templatePath = templatePath || path.join(__dirname, '../word_template.docx');
this.startPage = startPage; // Which page to start inserting content
this.pageSettings = pageSettings; // Page size and orientation settings
}
/**
* Convert markdown to Word document using template
*/
async convert(markdownContent, outputPath) {
try {
// Load template
const templateBuffer = fs.readFileSync(this.templatePath);
const zip = new PizZip(templateBuffer);
// Extract document.xml
let documentXml = zip.file('word/document.xml').asText();
// Set page size if settings provided
if (this.pageSettings) {
documentXml = this.setPageSize(documentXml);
}
// Parse markdown and generate Word XML
const newContentXml = this.markdownToWordXml(markdownContent);
// Insert new content after the specified start page
const modifiedXml = this.insertContentAfterPage(documentXml, newContentXml, this.startPage);
// Update the zip with modified XML
zip.file('word/document.xml', modifiedXml);
// Generate and save the new document
const newDocBuffer = zip.generate({ type: 'nodebuffer' });
fs.writeFileSync(outputPath, newDocBuffer);
return outputPath;
} catch (error) {
console.error('Error in Word export:', error);
throw error;
}
}
/**
* Set page size in document XML
*/
setPageSize(documentXml) {
// Import PAGE_SIZES from main process (need to pass as parameter)
const PAGE_SIZES = {
a4: { width: 11906, height: 16838 },
a3: { width: 16838, height: 23811 },
a5: { width: 8391, height: 11906 },
b4: { width: 14170, height: 20015 },
b5: { width: 9979, height: 14170 },
letter: { width: 12240, height: 15840 },
legal: { width: 12240, height: 20160 },
tabloid: { width: 15840, height: 24480 },
};
let width, height;
const pageSize = PAGE_SIZES[this.pageSettings.size];
if (pageSize) {
width = pageSize.width;
height = pageSize.height;
} else {
// Default to A4
width = 11906;
height = 16838;
}
// Swap dimensions for landscape
if (this.pageSettings.orientation === 'landscape') {
[width, height] = [height, width];
}
// Update all <w:pgSz> elements in section properties
const pgSzRegex = /<w:pgSz[^>]*\/>/g;
let modifiedXml = documentXml.replace(pgSzRegex, () => {
return `<w:pgSz w:w="${width}" w:h="${height}" w:orient="${this.pageSettings.orientation}"/>`;
});
// If no pgSz found, add it to all sectPr elements
if (!pgSzRegex.test(documentXml)) {
const sectPrRegex = /<w:sectPr[^>]*>/g;
modifiedXml = modifiedXml.replace(sectPrRegex, (match) => {
return `${match}<w:pgSz w:w="${width}" w:h="${height}" w:orient="${this.pageSettings.orientation}"/>`;
});
}
return modifiedXml;
}
/**
* Insert markdown content after the specified page in the document
* @param {string} documentXml - The document XML
* @param {string} newContentXml - The new content to insert
* @param {number} afterPage - Insert content after this page number (1-based)
*/
insertContentAfterPage(documentXml, newContentXml, afterPage) {
// Find section breaks that mark page boundaries
// Look for the section properties tag that marks page breaks
const sectionBreakRegex = /<w:sectPr[^>]*>[\s\S]*?<\/w:sectPr>/g;
const matches = [...documentXml.matchAll(sectionBreakRegex)];
// Calculate which section break to insert after
// Page 1 = before 1st section break
// Page 2 = after 1st section break
// Page 3 = after 2nd section break, etc.
const sectionIndex = afterPage - 1;
if (matches.length >= sectionIndex && sectionIndex > 0) {
// Insert after the specified section break
const insertPoint = matches[sectionIndex - 1].index + matches[sectionIndex - 1][0].length;
return documentXml.slice(0, insertPoint) + newContentXml + documentXml.slice(insertPoint);
} else if (afterPage === 1 || matches.length === 0) {
// Insert at the beginning (after <w:body>) or if no section breaks found
const bodyStart = documentXml.indexOf('<w:body>') + 8;
return documentXml.slice(0, bodyStart) + newContentXml + documentXml.slice(bodyStart);
} else {
// If not enough section breaks, insert before closing body tag
return documentXml.replace('</w:body>', newContentXml + '</w:body>');
}
}
/**
* Convert markdown to Word XML format
*/
markdownToWordXml(markdown) {
const lines = markdown.split('\n');
let xml = '';
let inCodeBlock = false;
let codeLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle code blocks
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
// End code block
xml += this.createCodeBlockXml(codeLines.join('\n'));
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
// Empty lines
if (!line.trim()) {
xml += '<w:p><w:pPr></w:pPr></w:p>';
continue;
}
// Tables - detect table lines
if (line.includes('|') && line.trim().startsWith('|')) {
const tableLines = [line];
i++;
// Collect all consecutive table lines
while (i < lines.length && lines[i].includes('|')) {
tableLines.push(lines[i]);
i++;
}
i--; // Back up one line
xml += this.createTableXml(tableLines);
continue;
}
// ASCII flowcharts/diagrams - detect box drawing characters
if (this.isAsciiArt(line)) {
const asciiLines = [line];
i++;
// Collect all consecutive ASCII art lines
while (i < lines.length && (this.isAsciiArt(lines[i]) || !lines[i].trim())) {
asciiLines.push(lines[i]);
i++;
if (i < lines.length && lines[i].trim() && !this.isAsciiArt(lines[i])) {
break;
}
}
i--; // Back up one line
xml += this.createAsciiArtXml(asciiLines.join('\n'));
continue;
}
// Headings - strip markdown numbering
if (line.trim().startsWith('#')) {
const level = (line.match(/^#+/) || [''])[0].length;
let text = line.replace(/^#+\s*/, '').trim();
// Remove markdown numbering like "1.1 Title" -> "Title"
text = text.replace(/^\d+(\.\d+)*\.?\s+/, '');
xml += this.createHeadingXml(text, level);
continue;
}
// Blockquotes
if (line.trim().startsWith('>')) {
const text = line.replace(/^>\s*/, '').trim();
xml += this.createQuoteXml(text);
continue;
}
// Ordered lists - strip markdown numbering, use template numbering
if (/^\s*\d+\.\s+/.test(line)) {
const text = line.replace(/^\s*\d+\.\s+/, '');
xml += this.createListItemXml(text, true);
continue;
}
// Unordered lists
if (/^\s*[-*+]\s+/.test(line)) {
const text = line.replace(/^\s*[-*+]\s+/, '');
xml += this.createListItemXml(text, false);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(line.trim())) {
xml += this.createHorizontalRuleXml();
continue;
}
// Normal paragraph
xml += this.createParagraphXml(line);
}
return xml;
}
/**
* Create heading XML using template styles
*/
createHeadingXml(text, level) {
const styleName = `Heading${level}`;
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="${styleName}"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create paragraph XML with Normal style
*/
createParagraphXml(text) {
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create quote XML
*/
createQuoteXml(text) {
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="Quote"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create list item XML using template numbering
*/
createListItemXml(text, numbered) {
const runs = this.parseInlineFormatting(text);
const numId = numbered ? '1' : '2'; // Template numbering IDs
return `<w:p>
<w:pPr>
<w:pStyle w:val="${numbered ? 'ListNumber' : 'ListBullet'}"/>
<w:numPr>
<w:ilvl w:val="0"/>
<w:numId w:val="${numId}"/>
</w:numPr>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create code block XML - renders each line as separate paragraph
* to preserve exact formatting like in preview (monospace, no wrapping)
* Background shading applied at paragraph level to extend full width
*/
createCodeBlockXml(code) {
const lines = code.split('\n');
let xml = '';
lines.forEach((line, index) => {
const escapedLine = this.escapeXml(line);
// Each line gets its own paragraph with exact spacing and no wrapping
// Shading (shd) is at paragraph level so background extends full width
xml += `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>
<w:spacing w:before="${index === 0 ? '120' : '0'}" w:after="${index === lines.length - 1 ? '120' : '0'}" w:line="240" w:lineRule="exact"/>
<w:ind w:left="284" w:right="284"/>
<w:jc w:val="left"/>
<w:keepLines/>
<w:wordWrap w:val="0"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="18"/>
<w:szCs w:val="18"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedLine}</w:t>
</w:r>
</w:p>`;
});
return xml;
}
/**
* Create horizontal rule XML
*/
createHorizontalRuleXml() {
return `<w:p>
<w:pPr>
<w:pBdr>
<w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/>
</w:pBdr>
</w:pPr>
</w:p>`;
}
/**
* Create table XML from markdown table lines with template styling
* Uses full-width tables with equal column distribution matching template style
*/
createTableXml(tableLines) {
// Parse table
const rows = [];
for (const line of tableLines) {
// Skip separator lines (e.g., |---|---|)
if (/^\s*\|[\s\-:]+\|\s*$/.test(line)) {
continue;
}
// Split by | and trim
const cells = line
.split('|')
.filter((cell) => cell.trim())
.map((cell) => cell.trim());
if (cells.length > 0) {
rows.push(cells);
}
}
if (rows.length === 0) return '';
// Calculate number of columns
const numCols = Math.max(...rows.map((row) => row.length));
// Calculate column width in twips (1440 twips = 1 inch)
// Assume standard page width of 9360 twips (6.5 inches usable)
const totalTableWidth = 9360;
const colWidth = Math.floor(totalTableWidth / numCols);
// Build table XML
let tableXml = '<w:tbl>';
// Table properties - use template table style with full width
tableXml += `<w:tblPr>
<w:tblStyle w:val="TableGrid"/>
<w:tblW w:w="0" w:type="auto"/>
<w:tblLayout w:type="fixed"/>
<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>
<w:tblBorders>
<w:top w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:left w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:bottom w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:right w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:insideH w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:insideV w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
</w:tblBorders>
</w:tblPr>`;
// Table grid with explicit column widths
tableXml += '<w:tblGrid>';
for (let i = 0; i < numCols; i++) {
tableXml += `<w:gridCol w:w="${colWidth}"/>`;
}
tableXml += '</w:tblGrid>';
// Table rows
rows.forEach((rowCells, rowIndex) => {
const isHeader = rowIndex === 0;
tableXml += '<w:tr>';
// Row properties for consistent height
tableXml += '<w:trPr><w:trHeight w:val="0" w:hRule="atLeast"/></w:trPr>';
// Pad row to have same number of columns
while (rowCells.length < numCols) {
rowCells.push('');
}
rowCells.forEach((cellText, _colIndex) => {
tableXml += '<w:tc>';
tableXml += '<w:tcPr>';
// Cell width
tableXml += `<w:tcW w:w="${colWidth}" w:type="dxa"/>`;
// Cell shading - orange for header, white for data rows
if (isHeader) {
tableXml += '<w:shd w:val="clear" w:color="auto" w:fill="F58220"/>';
} else {
tableXml += '<w:shd w:val="clear" w:color="auto" w:fill="FFFFFF"/>';
}
// Cell borders
tableXml +=
'<w:tcBorders>' +
'<w:top w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:left w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:bottom w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:right w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'</w:tcBorders>';
// Cell margins for proper padding
tableXml +=
'<w:tcMar>' +
'<w:top w:w="80" w:type="dxa"/>' +
'<w:left w:w="120" w:type="dxa"/>' +
'<w:bottom w:w="80" w:type="dxa"/>' +
'<w:right w:w="120" w:type="dxa"/>' +
'</w:tcMar>';
tableXml += '</w:tcPr>';
// Cell content
tableXml += '<w:p>';
tableXml += '<w:pPr>';
tableXml += '<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>';
tableXml += '</w:pPr>';
const runs = this.parseInlineFormatting(cellText);
if (isHeader) {
// Header: bold white text
tableXml += runs.replace(/<w:rPr>/g, '<w:rPr><w:b/><w:color w:val="FFFFFF"/>');
} else {
// Data rows: normal black text
tableXml += runs;
}
tableXml += '</w:p>';
tableXml += '</w:tc>';
});
tableXml += '</w:tr>';
});
tableXml += '</w:tbl>';
// Add spacing after table
tableXml += '<w:p><w:pPr><w:spacing w:before="120" w:after="0"/></w:pPr></w:p>';
return tableXml;
}
/**
* Detect if line contains ASCII art/flowchart characters
*/
isAsciiArt(line) {
// Don't treat markdown tables as ASCII art
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
// Check if it's a proper markdown table (has multiple cells)
const cells = line.split('|').filter((c) => c.trim());
if (cells.length >= 2) {
return false;
}
}
// Common ASCII art characters (Unicode box drawing and symbols)
const asciiArtChars = [
'─',
'│',
'┌',
'┐',
'└',
'┘',
'├',
'┤',
'┬',
'┴',
'┼', // Box drawing
'═',
'║',
'╔',
'╗',
'╚',
'╝',
'╠',
'╣',
'╦',
'╩',
'╬', // Double box
'╭',
'╮',
'╯',
'╰', // Rounded corners
'▲',
'▼',
'◄',
'►',
'♦',
'●',
'○',
'■',
'□',
'◆',
'◇', // Shapes
'↓',
'→',
'←',
'↑',
'↔',
'↕',
'⇒',
'⇐',
'⇓',
'⇑', // Arrows
'┃',
'━',
'┏',
'┓',
'┗',
'┛',
'┣',
'┫',
'┳',
'┻',
'╋', // Heavy box
'░',
'▒',
'▓',
'█', // Shading
];
// Check for box drawing characters
if (asciiArtChars.some((char) => line.includes(char))) {
return true;
}
// Check for ASCII box patterns with regular characters
const asciiPatterns = [
/^\s*\+[-=_+]+\+/, // +-----+ or +=====+
/^\s*\|[-=_]{3,}\|/, // |-----|
/^\s*[-=_]{5,}$/, // -----
/^\s*\+[-]+\+[-]+\+/, // +---+---+
/^\s*\([A-Z][a-z]+\)\s*\|\|/, // (Deformable) ||
/^\s*\|\s+[A-Z\s]+[-:]\s+[A-Z]/, // | TILE ADHESIVE - TYPE
/^\s*\|\s*\[[^\]]+\]/, // | [BIS Mark / CE Mark]
/^\s*\|\s{2,}\w+.*\|\|/, // || text ||
/^\s*\[[^\]]+\]\s*$/, // [Step in brackets]
/^\s*<[-=]+>/, // <----> or <====>
/^\s*\/[-_\\\/]+\//, // /----/
/^\s*\*[-=\*]+\*/, // *----*
];
return asciiPatterns.some((pattern) => pattern.test(line));
}
/**
* Create ASCII art XML with monospace font
* Background shading applied at paragraph level to extend full width
*/
createAsciiArtXml(asciiContent) {
// Split ASCII art into individual lines and create a paragraph for each
const lines = asciiContent.split('\n');
let xml = '';
lines.forEach((line, index) => {
// Shading (shd) is at paragraph level so background extends full width
xml += `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>
<w:spacing w:before="${index === 0 ? '120' : '0'}" w:after="${index === lines.length - 1 ? '120' : '0'}" w:line="240" w:lineRule="exact"/>
<w:ind w:left="284" w:right="284"/>
<w:jc w:val="left"/>
<w:keepLines/>
<w:wordWrap w:val="0"/>
</w:pPr>`;
// Check if line contains arrow characters and color them red
const arrowChars = ['↓', '→', '←', '↑', '▼', '►', '◄', '▲'];
const hasArrow = arrowChars.some((arrow) => line.includes(arrow));
if (hasArrow) {
// Split line into parts and color arrows red
let remainingLine = line;
while (remainingLine.length > 0) {
let foundArrow = false;
let arrowIndex = -1;
let foundArrowChar = '';
// Find the first arrow in the remaining text
for (const arrow of arrowChars) {
const idx = remainingLine.indexOf(arrow);
if (idx !== -1 && (arrowIndex === -1 || idx < arrowIndex)) {
arrowIndex = idx;
foundArrowChar = arrow;
foundArrow = true;
}
}
if (foundArrow) {
// Add text before arrow (if any)
if (arrowIndex > 0) {
const beforeArrow = this.escapeXml(remainingLine.substring(0, arrowIndex));
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${beforeArrow}</w:t>
</w:r>`;
}
// Add arrow in red
const escapedArrow = this.escapeXml(foundArrowChar);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:color w:val="FF0000"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedArrow}</w:t>
</w:r>`;
// Continue with remaining text
remainingLine = remainingLine.substring(arrowIndex + foundArrowChar.length);
} else {
// No more arrows, add remaining text
const escapedRemaining = this.escapeXml(remainingLine);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedRemaining}</w:t>
</w:r>`;
remainingLine = '';
}
}
} else {
// No arrows, just add the line normally
const escapedLine = this.escapeXml(line);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedLine}</w:t>
</w:r>`;
}
xml += `</w:p>`;
});
return xml;
}
/**
* Parse inline formatting (bold, italic, code)
*/
parseInlineFormatting(text) {
let xml = '';
const _pos = 0;
// Patterns for inline formatting
const patterns = [
{ regex: /\*\*\*(.+?)\*\*\*/g, bold: true, italic: true },
{ regex: /\*\*(.+?)\*\*/g, bold: true },
{ regex: /\*(.+?)\*/g, italic: true },
{ regex: /`(.+?)`/g, code: true },
];
// Simple approach: process text sequentially
let remaining = text;
while (remaining.length > 0) {
let foundMatch = false;
let earliestPos = remaining.length;
let matchedPattern = null;
let match = null;
// Find earliest match
for (const pattern of patterns) {
pattern.regex.lastIndex = 0;
const m = pattern.regex.exec(remaining);
if (m && m.index < earliestPos) {
earliestPos = m.index;
matchedPattern = pattern;
match = m;
foundMatch = true;
}
}
if (foundMatch) {
// Add text before match
if (earliestPos > 0) {
xml += this.createRunXml(remaining.substring(0, earliestPos));
}
// Add formatted text
xml += this.createRunXml(
match[1],
matchedPattern.bold,
matchedPattern.italic,
matchedPattern.code
);
remaining = remaining.substring(earliestPos + match[0].length);
} else {
// No more matches, add remaining text
xml += this.createRunXml(remaining);
break;
}
}
return xml;
}
/**
* Create a run (text segment) XML
*/
createRunXml(text, bold = false, italic = false, code = false) {
if (!text) return '';
const escapedText = this.escapeXml(text);
let propsXml = '<w:rPr>';
if (bold) propsXml += '<w:b/>';
if (italic) propsXml += '<w:i/>';
if (code) {
propsXml += '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>';
propsXml += '<w:sz w:val="20"/>';
}
propsXml += '</w:rPr>';
return `<w:r>${propsXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>`;
}
/**
* Escape XML special characters
*/
escapeXml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}
module.exports = WordTemplateExporter;
+1 -3
View File
@@ -5,9 +5,7 @@
"description": "Demonstrates the plugin system. Safe to delete.", "description": "Demonstrates the plugin system. Safe to delete.",
"icon": "puzzle", "icon": "puzzle",
"extensionPoints": { "extensionPoints": {
"commands": [ "commands": [{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }]
{ "id": "hello", "label": "Sample: Hello World", "shortcut": "" }
]
}, },
"settings": [] "settings": []
} }
+69 -39
View File
@@ -9,7 +9,7 @@ class WritingStudioPlugin extends PluginAPI {
this.context = context; this.context = context;
this.sprintEngine = new SprintEngine({ this.sprintEngine = new SprintEngine({
onEvent: (name, data) => context.events.emit(name, data) onEvent: (name, data) => context.events.emit(name, data),
}); });
this.goalTracker = new GoalTracker(context.settings); this.goalTracker = new GoalTracker(context.settings);
this.snapshotManager = new SnapshotManager(context.settings); this.snapshotManager = new SnapshotManager(context.settings);
@@ -17,14 +17,14 @@ class WritingStudioPlugin extends PluginAPI {
readFile: (p) => context.ipc.invoke('read-file', p), readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', p, c), writeFile: (p, c) => context.ipc.invoke('write-file', p, c),
fileExists: (p) => context.ipc.invoke('path-exists', p), fileExists: (p) => context.ipc.invoke('path-exists', p),
listDir: (p) => context.ipc.invoke('list-directory', p) listDir: (p) => context.ipc.invoke('list-directory', p),
}); });
this._engines = { this._engines = {
sprint: this.sprintEngine, sprint: this.sprintEngine,
goals: this.goalTracker, goals: this.goalTracker,
snapshots: this.snapshotManager, snapshots: this.snapshotManager,
projects: this.projectManager projects: this.projectManager,
}; };
this._registerCommands(context); this._registerCommands(context);
@@ -34,59 +34,89 @@ class WritingStudioPlugin extends PluginAPI {
_registerCommands(context) { _registerCommands(context) {
const { sprintEngine, snapshotManager, goalTracker } = this; const { sprintEngine, snapshotManager, goalTracker } = this;
context.commands.register('start-sprint', 'Studio: Start Sprint', () => { context.commands.register(
const duration = context.settings.get('sprintDuration') || 25; 'start-sprint',
const content = context.editor.getContent() || ''; 'Studio: Start Sprint',
const words = content.split(/\s+/).filter(Boolean).length; () => {
sprintEngine.start(duration, words); const duration = context.settings.get('sprintDuration') || 25;
}, 'Ctrl+Alt+S'); const content = context.editor.getContent() || '';
const words = content.split(/\s+/).filter(Boolean).length;
sprintEngine.start(duration, words);
},
'Ctrl+Alt+S'
);
context.commands.register('stop-sprint', 'Studio: Stop Sprint', () => { context.commands.register(
if (!sprintEngine.isActive()) return; 'stop-sprint',
const content = context.editor.getContent() || ''; 'Studio: Stop Sprint',
const words = content.split(/\s+/).filter(Boolean).length; () => {
const result = sprintEngine.stop(words); if (!sprintEngine.isActive()) return;
goalTracker.addWords(result.wordDelta); const content = context.editor.getContent() || '';
context.events.emit('sprint:stopped', result); const words = content.split(/\s+/).filter(Boolean).length;
}, 'Ctrl+Alt+Shift+S'); const result = sprintEngine.stop(words);
goalTracker.addWords(result.wordDelta);
context.events.emit('sprint:stopped', result);
},
'Ctrl+Alt+Shift+S'
);
context.commands.register('take-snapshot', 'Studio: Take Snapshot', () => { context.commands.register(
const content = context.editor.getContent() || ''; 'take-snapshot',
snapshotManager.create(content, 'manual'); 'Studio: Take Snapshot',
context.events.emit('snapshot:created', {}); () => {
}, 'Ctrl+Alt+N'); const content = context.editor.getContent() || '';
snapshotManager.create(content, 'manual');
context.events.emit('snapshot:created', {});
},
'Ctrl+Alt+N'
);
context.commands.register('restore-last-snapshot', 'Studio: Restore Last Snapshot', () => { context.commands.register(
const snaps = snapshotManager.list(); 'restore-last-snapshot',
if (snaps.length === 0) return; 'Studio: Restore Last Snapshot',
const content = snapshotManager.restore(snaps[0].id); () => {
context.editor.insertAtCursor(content); const snaps = snapshotManager.list();
}, 'Ctrl+Alt+Z'); if (snaps.length === 0) return;
const content = snapshotManager.restore(snaps[0].id);
context.editor.insertAtCursor(content);
},
'Ctrl+Alt+Z'
);
context.commands.register('new-project', 'Studio: New Project', () => { context.commands.register('new-project', 'Studio: New Project', () => {
context.events.emit('studio:new-project', {}); context.events.emit('studio:new-project', {});
}); });
context.commands.register('compile-manuscript', 'Studio: Compile Manuscript', () => { context.commands.register(
context.events.emit('studio:compile', {}); 'compile-manuscript',
}, 'Ctrl+Alt+E'); 'Studio: Compile Manuscript',
() => {
context.events.emit('studio:compile', {});
},
'Ctrl+Alt+E'
);
context.commands.register('proofread-document', 'Studio: Proofread Document', () => { context.commands.register(
if (context.events.hasHandler('ai:analyze')) { 'proofread-document',
const content = context.editor.getContent() || ''; 'Studio: Proofread Document',
context.events.emit('ai:analyze', { text: content, type: 'grammar' }); () => {
} if (context.events.hasHandler('ai:analyze')) {
}, 'Ctrl+Alt+G'); const content = context.editor.getContent() || '';
context.events.emit('ai:analyze', { text: content, type: 'grammar' });
}
},
'Ctrl+Alt+G'
);
} }
_registerStatusBar(context) { _registerStatusBar(context) {
context.statusBar.registerIndicator('word-goal', { context.statusBar.registerIndicator('word-goal', {
text: '0/1000', text: '0/1000',
tooltip: 'Daily word goal progress' tooltip: 'Daily word goal progress',
}); });
context.statusBar.registerIndicator('sprint-timer', { context.statusBar.registerIndicator('sprint-timer', {
text: '', text: '',
tooltip: 'Writing sprint timer' tooltip: 'Writing sprint timer',
}); });
} }
@@ -15,17 +15,34 @@
{ "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" }, { "id": "start-sprint", "label": "Studio: Start Sprint", "shortcut": "Ctrl+Alt+S" },
{ "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" }, { "id": "stop-sprint", "label": "Studio: Stop Sprint", "shortcut": "Ctrl+Alt+Shift+S" },
{ "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" }, { "id": "take-snapshot", "label": "Studio: Take Snapshot", "shortcut": "Ctrl+Alt+N" },
{ "id": "restore-last-snapshot", "label": "Studio: Restore Last Snapshot", "shortcut": "Ctrl+Alt+Z" }, {
"id": "restore-last-snapshot",
"label": "Studio: Restore Last Snapshot",
"shortcut": "Ctrl+Alt+Z"
},
{ "id": "new-project", "label": "Studio: New Project", "shortcut": "" }, { "id": "new-project", "label": "Studio: New Project", "shortcut": "" },
{ "id": "compile-manuscript", "label": "Studio: Compile Manuscript", "shortcut": "Ctrl+Alt+E" }, {
{ "id": "proofread-document", "label": "Studio: Proofread Document", "shortcut": "Ctrl+Alt+G" } "id": "compile-manuscript",
"label": "Studio: Compile Manuscript",
"shortcut": "Ctrl+Alt+E"
},
{
"id": "proofread-document",
"label": "Studio: Proofread Document",
"shortcut": "Ctrl+Alt+G"
}
], ],
"statusBar": { "indicators": ["sprint-timer", "word-goal"] } "statusBar": { "indicators": ["sprint-timer", "word-goal"] }
}, },
"settings": [ "settings": [
{ "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" }, { "key": "dailyGoal", "type": "number", "default": 1000, "label": "Daily word goal" },
{ "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" }, { "key": "sprintDuration", "type": "number", "default": 25, "label": "Sprint duration (min)" },
{ "key": "autoSnapshotInterval", "type": "number", "default": 0, "label": "Auto-snapshot interval (min, 0=off)" }, {
"key": "autoSnapshotInterval",
"type": "number",
"default": 0,
"label": "Auto-snapshot interval (min, 0=off)"
},
{ "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" } { "key": "maxSnapshots", "type": "number", "default": 50, "label": "Max snapshots to keep" }
] ]
} }
@@ -29,7 +29,8 @@ function renderGoalsPanel(container, { engines, settings }) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ws-stat-row'; row.className = 'ws-stat-row';
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words'; label.textContent =
progress.written.toLocaleString() + ' / ' + dailyGoal.toLocaleString() + ' words';
const pct = document.createElement('span'); const pct = document.createElement('span');
pct.className = 'ws-pct'; pct.className = 'ws-pct';
pct.textContent = progress.pct + '%'; pct.textContent = progress.pct + '%';
@@ -81,7 +82,7 @@ function renderGoalsPanel(container, { engines, settings }) {
const chart = document.createElement('div'); const chart = document.createElement('div');
chart.className = 'ws-chart'; chart.className = 'ws-chart';
const maxWords = Math.max(...last30.map(d => d.words), 1); const maxWords = Math.max(...last30.map((d) => d.words), 1);
for (const day of last30) { for (const day of last30) {
const barEl = document.createElement('div'); const barEl = document.createElement('div');
const height = Math.max(2, (day.words / maxWords) * 60); const height = Math.max(2, (day.words / maxWords) * 60);
@@ -69,7 +69,8 @@ function renderManuscriptPanel(container, { engines, editor, settings }) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'ws-stat-row'; row.className = 'ws-stat-row';
const label = document.createElement('span'); const label = document.createElement('span');
label.textContent = stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words'; label.textContent =
stats.totalWords.toLocaleString() + ' / ' + stats.targetWords.toLocaleString() + ' words';
const pct = document.createElement('span'); const pct = document.createElement('span');
pct.className = 'ws-pct'; pct.className = 'ws-pct';
pct.textContent = stats.pctComplete + '%'; pct.textContent = stats.pctComplete + '%';
@@ -42,7 +42,7 @@ function renderProofreadPanel(container, { events, editor }) {
if (result && result.issues) { if (result && result.issues) {
renderIssues(issuesList, result.issues); renderIssues(issuesList, result.issues);
} }
} },
}); });
}); });
} }
@@ -80,7 +80,10 @@ function renderIssues(container, issues) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'ws-issue-actions'; actions.className = 'ws-issue-actions';
for (const [action, label] of [['accept', 'Accept'], ['dismiss', 'Dismiss']]) { for (const [_action, label] of [
['accept', 'Accept'],
['dismiss', 'Dismiss'],
]) {
const actionBtn = document.createElement('button'); const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm'; actionBtn.className = 'ws-btn ws-btn-sm';
actionBtn.textContent = label; actionBtn.textContent = label;
@@ -45,7 +45,11 @@ function renderSnapshotsPanel(container, { engines, editor }) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'ws-snapshot-actions'; actions.className = 'ws-snapshot-actions';
for (const [action, text, cls] of [['restore', 'Restore', ''], ['diff', 'Diff', ''], ['delete', 'Delete', 'ws-btn-danger']]) { for (const [action, text, cls] of [
['restore', 'Restore', ''],
['diff', 'Diff', ''],
['delete', 'Delete', 'ws-btn-danger'],
]) {
const actionBtn = document.createElement('button'); const actionBtn = document.createElement('button');
actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : ''); actionBtn.className = 'ws-btn ws-btn-sm' + (cls ? ' ' + cls : '');
actionBtn.textContent = text; actionBtn.textContent = text;
@@ -12,7 +12,7 @@ class ProjectManager {
type: opts.type || 'manuscript', type: opts.type || 'manuscript',
target: { words: opts.targetWords || 0, deadline: opts.deadline || null }, target: { words: opts.targetWords || 0, deadline: opts.deadline || null },
chapters: [], chapters: [],
metadata: opts.metadata || {} metadata: opts.metadata || {},
}; };
this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2)); this.fs.writeFile(dir + '/.project.json', JSON.stringify(project, null, 2));
return project; return project;
@@ -66,7 +66,7 @@ class ProjectManager {
totalWords, totalWords,
chapterCount: project.chapters.length, chapterCount: project.chapters.length,
targetWords: target, targetWords: target,
pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0 pctComplete: target > 0 ? Math.min(100, Math.round((totalWords / target) * 100)) : 0,
}; };
} }
} }
@@ -24,7 +24,7 @@ class SnapshotManager {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
content, content,
wordCount: content.split(/\s+/).filter(Boolean).length, wordCount: content.split(/\s+/).filter(Boolean).length,
label label,
}; };
snaps.unshift(snap); snaps.unshift(snap);
this._saveAll(snaps); this._saveAll(snaps);
@@ -36,7 +36,7 @@ class SnapshotManager {
} }
getById(id) { getById(id) {
return this._getAll().find(s => s.id === id) || null; return this._getAll().find((s) => s.id === id) || null;
} }
restore(id) { restore(id) {
@@ -46,7 +46,7 @@ class SnapshotManager {
} }
delete(id) { delete(id) {
const snaps = this._getAll().filter(s => s.id !== id); const snaps = this._getAll().filter((s) => s.id !== id);
this._saveAll(snaps); this._saveAll(snaps);
} }
@@ -59,8 +59,12 @@ class SnapshotManager {
const newSet = new Set(newLines); const newSet = new Set(newLines);
let added = 0; let added = 0;
let removed = 0; let removed = 0;
for (const line of newLines) { if (!oldSet.has(line)) added++; } for (const line of newLines) {
for (const line of oldLines) { if (!newSet.has(line)) removed++; } if (!oldSet.has(line)) added++;
}
for (const line of oldLines) {
if (!newSet.has(line)) removed++;
}
return { added, removed }; return { added, removed };
} }
@@ -40,7 +40,9 @@ class SprintEngine {
} }
} }
isActive() { return this._active; } isActive() {
return this._active;
}
getRemaining() { getRemaining() {
if (!this._active) return 0; if (!this._active) return 0;
+15 -10
View File
@@ -12,10 +12,11 @@ class PluginContext {
* @param {object} deps.exportHooks - { preHooks: [], postHooks: [] } * @param {object} deps.exportHooks - { preHooks: [], postHooks: [] }
*/ */
constructor(deps) { constructor(deps) {
const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } = deps; const { pluginId, sidebar, commands, statusBar, eventBus, settings, editor, ipc, exportHooks } =
deps;
this.sidebar = { this.sidebar = {
registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts) registerPanel: (id, opts) => sidebar.registerPanel(`${pluginId}:${id}`, opts),
}; };
this.commands = { this.commands = {
@@ -28,41 +29,45 @@ class PluginContext {
} }
}; };
commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut); commands.register(`${pluginId}:${id}`, label, safeHandler, shortcut);
} },
}; };
this.statusBar = { this.statusBar = {
registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts) registerIndicator: (id, opts) => statusBar.registerIndicator(`${pluginId}:${id}`, opts),
}; };
this.settings = { this.settings = {
get: (key) => settings.get(`plugins.${pluginId}.${key}`), get: (key) => settings.get(`plugins.${pluginId}.${key}`),
set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value), set: (key, value) => settings.set(`plugins.${pluginId}.${key}`, value),
onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb) onChanged: (key, cb) => settings.onChanged(`plugins.${pluginId}.${key}`, cb),
}; };
this.editor = { this.editor = {
getContent: () => editor.getContent(), getContent: () => editor.getContent(),
getSelection: () => editor.getSelection(), getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text), insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb) onContentChanged: (cb) => editor.onContentChanged(cb),
}; };
this.events = { this.events = {
on: (event, handler) => eventBus.on(event, handler), on: (event, handler) => eventBus.on(event, handler),
off: (event, handler) => eventBus.off(event, handler), off: (event, handler) => eventBus.off(event, handler),
emit: (event, payload) => eventBus.emit(event, payload), emit: (event, payload) => eventBus.emit(event, payload),
hasHandler: (event) => eventBus.hasHandler(event) hasHandler: (event) => eventBus.hasHandler(event),
}; };
this.ipc = { this.ipc = {
invoke: (channel, ...args) => ipc.invoke(channel, ...args), invoke: (channel, ...args) => ipc.invoke(channel, ...args),
on: (channel, handler) => ipc.on(channel, handler) on: (channel, handler) => ipc.on(channel, handler),
}; };
this.exports = { this.exports = {
registerPreHook: (handler) => { if (exportHooks) exportHooks.preHooks.push(handler); }, registerPreHook: (handler) => {
registerPostHook: (handler) => { if (exportHooks) exportHooks.postHooks.push(handler); } if (exportHooks) exportHooks.preHooks.push(handler);
},
registerPostHook: (handler) => {
if (exportHooks) exportHooks.postHooks.push(handler);
},
}; };
} }
} }
+5 -2
View File
@@ -35,7 +35,10 @@ class PluginLoader {
const loaded = require(indexPath); const loaded = require(indexPath);
PluginClass = loaded.Plugin || loaded.default || null; PluginClass = loaded.Plugin || loaded.default || null;
} catch (err) { } catch (err) {
console.error(`[PluginLoader] Failed to load index.js for "${manifest.id}":`, err.message); console.error(
`[PluginLoader] Failed to load index.js for "${manifest.id}":`,
err.message
);
continue; continue;
} }
} }
@@ -46,7 +49,7 @@ class PluginLoader {
description: manifest.description, description: manifest.description,
manifest, manifest,
PluginClass, PluginClass,
dir: pluginDir dir: pluginDir,
}); });
this.loadedIds.add(manifest.id); this.loadedIds.add(manifest.id);
} catch (err) { } catch (err) {
+1 -1
View File
@@ -32,7 +32,7 @@ class PluginRegistry {
settings: this.deps.settings, settings: this.deps.settings,
editor: this.deps.editor, editor: this.deps.editor,
ipc: this.deps.ipc, ipc: this.deps.ipc,
exportHooks: this.exportHooks exportHooks: this.exportHooks,
}); });
try { try {
+1 -1
View File
@@ -14,7 +14,7 @@ class SettingsStore {
this.backend.set(key, value); this.backend.set(key, value);
} }
onChanged(key, callback) { onChanged(_key, _callback) {
// Deferred: plugins read settings on init/activate for MVP. // Deferred: plugins read settings on init/activate for MVP.
// Full change notification requires IPC watcher in main process. // Full change notification requires IPC watcher in main process.
} }
+87 -38
View File
@@ -85,12 +85,6 @@ const ALLOWED_SEND_CHANNELS = [
'get-pdf-page-count', 'get-pdf-page-count',
'select-pdf-folder', 'select-pdf-folder',
// ASCII generator (separate window)
'open-ascii-generator',
// Table generator (separate window)
'open-table-generator',
// Insert generated content // Insert generated content
'insert-generated-content', 'insert-generated-content',
@@ -104,12 +98,16 @@ const ALLOWED_SEND_CHANNELS = [
'list-directory', 'list-directory',
'read-file', 'read-file',
'write-file', 'write-file',
'write-buffer',
'read-buffer',
'delete-file', 'delete-file',
'ensure-directory', 'ensure-directory',
'path-exists', 'path-exists',
'is-directory', 'is-directory',
'copy-path', 'copy-path',
'move-path', 'move-path',
'pick-folder',
'pick-file',
// Git // Git
'git-status', 'git-status',
@@ -135,12 +133,28 @@ const ALLOWED_SEND_CHANNELS = [
'menu-open', 'menu-open',
'export', 'export',
// App lifecycle
'app:quit',
'app:open-external',
'app:show-save-dialog',
'get-app-version',
// Updater
'updater:check',
'updater:install',
'updater:get-state',
// Crash reporter
'crash:read',
'crash:open-dir',
'crash:delete',
// Git diff // Git diff
'git-diff', 'git-diff',
// Plugin settings // Plugin settings
'plugin-settings:get', 'plugin-settings:get',
'plugin-settings:set' 'plugin-settings:set',
]; ];
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -169,15 +183,10 @@ const ALLOWED_RECEIVE_CHANNELS = [
// Font // Font
'adjust-font-size', 'adjust-font-size',
// Print
'print-preview',
'print-preview-styled',
// Export dialogs // Export dialogs
'show-export-dialog', 'show-export-dialog',
'show-batch-dialog', 'show-batch-dialog',
'show-universal-converter-dialog', 'show-universal-converter-dialog',
'show-table-generator',
'show-pdf-editor-dialog', 'show-pdf-editor-dialog',
// Converter dialogs // Converter dialogs
@@ -213,12 +222,7 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pdf-operation-complete', 'pdf-operation-complete',
'pdf-operation-error', 'pdf-operation-error',
// ASCII Art Generator
'show-ascii-generator-window',
'show-ascii-generator',
// Table Generator // Table Generator
'show-table-generator-window',
// Header/Footer dialog // Header/Footer dialog
'open-header-footer-dialog', 'open-header-footer-dialog',
@@ -233,16 +237,25 @@ const ALLOWED_RECEIVE_CHANNELS = [
// Batch converter // Batch converter
'show-batch-converter', 'show-batch-converter',
// Document compare
'show-document-compare',
// v4 menu-triggered events // v4 menu-triggered events
'load-template-menu', 'load-template-menu',
'toggle-command-palette',
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel', 'toggle-bottom-panel',
'print-preview',
'print-preview-styled',
'clear-recent-files',
// Updater
'updater:status',
'show-analytics-dialog',
// File dialog / directory listing // File dialog / directory listing
'list-directory', 'list-directory',
'pick-folder', 'pick-folder',
'pick-file' 'pick-file',
]; ];
/** /**
@@ -344,6 +357,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
rendererReady: () => ipcRenderer.send('renderer-ready'), rendererReady: () => ipcRenderer.send('renderer-ready'),
read: (filePath) => ipcRenderer.invoke('read-file', filePath), read: (filePath) => ipcRenderer.invoke('read-file', filePath),
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }), write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
readBuffer: (filePath) => ipcRenderer.invoke('read-buffer', { path: filePath }),
writeBuffer: (filePath, buffer) => ipcRenderer.invoke('write-buffer', { path: filePath, buffer }),
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath), delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath), exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
@@ -352,23 +367,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }), move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath), list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
pickFolder: () => ipcRenderer.invoke('pick-folder'), pickFolder: () => ipcRenderer.invoke('pick-folder'),
pickFile: () => ipcRenderer.invoke('pick-file') pickFile: () => ipcRenderer.invoke('pick-file'),
}, },
// Theme Operations // Theme Operations
theme: { theme: {
get: () => ipcRenderer.send('get-theme') get: () => ipcRenderer.send('get-theme'),
}, },
// Print Operations // Print Operations
print: { print: {
doPrint: (options) => ipcRenderer.send('do-print', options) doPrint: (options) => ipcRenderer.send('do-print', options),
show: (payload) => ipcRenderer.send('do-print', payload),
}, },
// Export Operations // Export Operations
export: { export: {
withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }), withOptions: (format, options) => ipcRenderer.send('export-with-options', { format, options }),
spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }) spreadsheet: (content, format) => ipcRenderer.send('export-spreadsheet', { content, format }),
}, },
// Batch Conversion // Batch Conversion
@@ -376,7 +392,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
convert: (inputFolder, outputFolder, format, options) => { convert: (inputFolder, outputFolder, format, options) => {
ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options }); ipcRenderer.send('batch-convert', { inputFolder, outputFolder, format, options });
}, },
selectFolder: (type) => ipcRenderer.send('select-folder', type) selectFolder: (type) => ipcRenderer.send('select-folder', type),
}, },
// Universal Converter // Universal Converter
@@ -385,8 +401,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath }); ipcRenderer.send('universal-convert', { tool, fromFormat, toFormat, filePath });
}, },
convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => { convertBatch: (tool, fromFormat, toFormat, inputFolder, outputFolder) => {
ipcRenderer.send('universal-convert-batch', { tool, fromFormat, toFormat, inputFolder, outputFolder }); ipcRenderer.send('universal-convert-batch', {
} tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
},
}, },
// Header/Footer Operations // Header/Footer Operations
@@ -394,22 +416,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.send('get-header-footer-settings'), getSettings: () => ipcRenderer.send('get-header-footer-settings'),
saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings), saveSettings: (settings) => ipcRenderer.send('save-header-footer-settings', settings),
browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position), browseLogo: (position) => ipcRenderer.send('browse-header-footer-logo', position),
saveLogo: (position, filePath) => ipcRenderer.send('save-header-footer-logo', { position, filePath }), saveLogo: (position, filePath) =>
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position) ipcRenderer.send('save-header-footer-logo', { position, filePath }),
clearLogo: (position) => ipcRenderer.send('clear-header-footer-logo', position),
}, },
// Page Settings // Page Settings
page: { page: {
getSettings: () => ipcRenderer.send('get-page-settings'), getSettings: () => ipcRenderer.send('get-page-settings'),
updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings), updateSettings: (settings) => ipcRenderer.send('update-page-settings', settings),
setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber) setCustomStartPage: (pageNumber) => ipcRenderer.send('set-custom-start-page', pageNumber),
}, },
// PDF Operations // PDF Operations
pdf: { pdf: {
processOperation: (data) => ipcRenderer.send('process-pdf-operation', data), processOperation: (data) => ipcRenderer.send('process-pdf-operation', data),
getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath), getPageCount: (filePath) => ipcRenderer.send('get-pdf-page-count', filePath),
selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId) selectFolder: (inputId) => ipcRenderer.send('select-pdf-folder', inputId),
}, },
// Image Converter Operations // Image Converter Operations
@@ -418,7 +441,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('image-batch-convert', data), batchConvert: (data) => ipcRenderer.send('image-batch-convert', data),
resize: (data) => ipcRenderer.send('image-resize', data), resize: (data) => ipcRenderer.send('image-resize', data),
compress: (data) => ipcRenderer.send('image-compress', data), compress: (data) => ipcRenderer.send('image-compress', data),
rotate: (data) => ipcRenderer.send('image-rotate', data) rotate: (data) => ipcRenderer.send('image-rotate', data),
}, },
// Audio Converter Operations // Audio Converter Operations
@@ -427,7 +450,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data), batchConvert: (data) => ipcRenderer.send('audio-batch-convert', data),
extract: (data) => ipcRenderer.send('audio-extract', data), extract: (data) => ipcRenderer.send('audio-extract', data),
trim: (data) => ipcRenderer.send('audio-trim', data), trim: (data) => ipcRenderer.send('audio-trim', data),
merge: (data) => ipcRenderer.send('audio-merge', data) merge: (data) => ipcRenderer.send('audio-merge', data),
}, },
// Video Converter Operations // Video Converter Operations
@@ -437,16 +460,42 @@ contextBridge.exposeInMainWorld('electronAPI', {
compress: (data) => ipcRenderer.send('video-compress', data), compress: (data) => ipcRenderer.send('video-compress', data),
trim: (data) => ipcRenderer.send('video-trim', data), trim: (data) => ipcRenderer.send('video-trim', data),
extractFrames: (data) => ipcRenderer.send('video-frames', data), extractFrames: (data) => ipcRenderer.send('video-frames', data),
toGif: (data) => ipcRenderer.send('video-gif', data) toGif: (data) => ipcRenderer.send('video-gif', data),
}, },
// Generator Windows getAppVersion: () => ipcRenderer.invoke('get-app-version'),
generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'), // Git Operations
openTable: () => ipcRenderer.send('open-table-generator') git: {
status: (rootPath) => ipcRenderer.invoke('git-status', rootPath),
stage: (args) => ipcRenderer.invoke('git-stage', args),
commit: (args) => ipcRenderer.invoke('git-commit', args),
log: (rootPath) => ipcRenderer.invoke('git-log', rootPath),
diff: (filePath) => ipcRenderer.invoke('git-diff', filePath),
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version') app: {
quit: () => ipcRenderer.send('app:quit'),
openExternal: (url) => ipcRenderer.send('app:open-external', url),
showSaveDialog: (args) => ipcRenderer.invoke('app:show-save-dialog', args),
},
updater: {
check: () => ipcRenderer.invoke('updater:check'),
install: () => ipcRenderer.invoke('updater:install'),
getState: () => ipcRenderer.invoke('updater:get-state'),
onStatus: (cb) => {
const subscription = (_event, payload) => cb(payload);
ipcRenderer.on('updater:status', subscription);
return () => ipcRenderer.removeListener('updater:status', subscription);
},
},
crash: {
read: () => ipcRenderer.invoke('crash:read'),
openDir: () => ipcRenderer.send('crash:open-dir'),
delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
},
}); });
// Log successful preload initialization // Log successful preload initialization
-138
View File
@@ -1,138 +0,0 @@
class PrintPreview {
constructor() {
this.overlay = document.getElementById('print-preview-overlay');
this.modal = window.modals?.printPreviewModal;
this._lastContent = '';
this.setupEventListeners();
}
open(htmlContent) {
this._lastContent = htmlContent;
if (this.modal) {
this.modal.open();
} else {
this.overlay.classList.remove('hidden');
}
this.updatePreview(htmlContent);
this.updateScaleLabel();
}
close() {
if (this.modal) {
this.modal.close();
} else {
this.overlay.classList.add('hidden');
}
}
setupEventListeners() {
document.getElementById('print-preview-close')?.addEventListener('click', () => this.close());
document.getElementById('print-cancel')?.addEventListener('click', () => this.close());
document.getElementById('print-execute')?.addEventListener('click', () => this.executePrint());
// Update preview on option changes
['print-paper-size', 'print-orientation', 'print-margins'].forEach(id => {
document.getElementById(id)?.addEventListener('change', () => this.refreshPreview());
});
// Scale slider
const scaleSlider = document.getElementById('print-scale');
scaleSlider?.addEventListener('input', () => this.updateScaleLabel());
// Page range toggle
document.getElementById('print-pages')?.addEventListener('change', (e) => {
const rangeInput = document.getElementById('print-page-range');
if (rangeInput) {
rangeInput.classList.toggle('hidden', e.target.value !== 'custom');
}
});
// Note: Backdrop click and Escape key are now handled by ModalManager
}
updateScaleLabel() {
const scale = document.getElementById('print-scale')?.value || 100;
const label = document.getElementById('print-scale-value');
if (label) label.textContent = `${scale}%`;
}
updatePreview(htmlContent) {
const frame = document.getElementById('print-preview-frame');
if (!frame) return;
this._lastContent = htmlContent;
const orientation = document.getElementById('print-orientation')?.value || 'portrait';
const paperSize = document.getElementById('print-paper-size')?.value || 'A4';
// Get dimensions for paper size
const sizes = {
'A3': { width: '297mm', height: '420mm' },
'A4': { width: '210mm', height: '297mm' },
'A5': { width: '148mm', height: '210mm' },
'Letter': { width: '8.5in', height: '11in' },
'Legal': { width: '8.5in', height: '14in' },
'Tabloid': { width: '11in', height: '17in' },
};
const size = sizes[paperSize] || sizes['A4'];
const width = orientation === 'landscape' ? size.height : size.width;
const height = orientation === 'landscape' ? size.width : size.height;
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.6;
}
@page { size: ${width} ${height}; }
pre { background: #f5f5f5; padding: 12px; border-radius: 6px; overflow-x: auto; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 16px; color: #666; }
img { max-width: 100%; }
h1, h2, h3 { margin-top: 1.5em; }
</style>
</head>
<body>${htmlContent || ''}</body>
</html>
`;
frame.srcdoc = previewHtml;
}
refreshPreview() {
if (this._lastContent) {
this.updatePreview(this._lastContent);
}
}
getOptions() {
return {
paperSize: document.getElementById('print-paper-size')?.value || 'A4',
orientation: document.getElementById('print-orientation')?.value || 'portrait',
margins: document.getElementById('print-margins')?.value || 'default',
scale: parseInt(document.getElementById('print-scale')?.value || '100'),
headers: document.getElementById('print-headers')?.checked ?? true,
background: document.getElementById('print-background')?.checked ?? true,
pages: document.getElementById('print-pages')?.value || 'all',
pageRange: document.getElementById('print-page-range')?.value || '',
};
}
executePrint() {
const options = this.getOptions();
const { ipcRenderer } = require('electron');
ipcRenderer.send('do-print-with-options', options);
this.close();
}
}
module.exports = { PrintPreview };
-5319
View File
File diff suppressed because it is too large Load Diff
+74 -1
View File
@@ -1,26 +1,99 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ModalLayer } from './components/modals/ModalLayer'; import { ModalLayer } from './components/modals/ModalLayer';
import { CommandPalette } from './components/modals/CommandPalette';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import { ReplPanel } from './components/tools/ReplPanel'; import { ReplPanel } from './components/tools/ReplPanel';
import { PrintPreview } from './components/tools/PrintPreview'; import { PrintPreview } from './components/tools/PrintPreview';
import { UpdateBanner } from './components/UpdateBanner';
import { FirstRunWizard } from './components/FirstRunWizard';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger'; import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck';
import { useSettingsStore } from './stores/settings-store';
import { ipc } from './lib/ipc';
import { toast } from './lib/toast';
function scopeCSS(cssText: string, scopeSelector: string) {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (
!trimmed ||
trimmed.startsWith('@') ||
trimmed.startsWith(':root') ||
trimmed.startsWith('from') ||
trimmed.startsWith('to') ||
/^\d+%$/.test(trimmed)
) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
}
function App() { function App() {
useWelcomeTrigger(); useWelcomeTrigger();
useAutoUpdateCheck();
const [printOpen, setPrintOpen] = useState(false); const [printOpen, setPrintOpen] = useState(false);
const customCssPath = useSettingsStore((s) => s.customCssPath);
useEffect(() => {
let active = true;
const styleId = 'custom-preview-style';
async function applyCSS() {
if (!customCssPath) {
const styleTag = document.getElementById(styleId);
if (styleTag) styleTag.remove();
return;
}
const r = await ipc.file.read(customCssPath);
if (!active) return;
if (r.ok && r.data) {
let styleTag = document.getElementById(styleId);
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = styleId;
document.head.appendChild(styleTag);
}
styleTag.textContent = scopeCSS(r.data, '.preview-content');
} else {
toast.error('Failed to load custom CSS file');
}
}
void applyCSS();
return () => {
active = false;
};
}, [customCssPath]);
useEffect(() => {
window.electronAPI?.file?.rendererReady?.();
}, []);
useEffect(() => { useEffect(() => {
const handler = () => setPrintOpen(true); const handler = () => setPrintOpen(true);
window.addEventListener('mc:print', handler); window.addEventListener('mc:print', handler);
return () => window.removeEventListener('mc:print', handler); window.addEventListener('mc:print-preview', handler);
window.addEventListener('mc:print-preview-styled', handler);
return () => {
window.removeEventListener('mc:print', handler);
window.removeEventListener('mc:print-preview', handler);
window.removeEventListener('mc:print-preview-styled', handler);
};
}, []); }, []);
return ( return (
<> <>
<AppShell /> <AppShell />
<ModalLayer /> <ModalLayer />
<CommandPalette />
<Toaster /> <Toaster />
<UpdateBanner />
<FirstRunWizard />
<ReplPanel /> <ReplPanel />
{printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />} {printOpen && <PrintPreview onClose={() => setPrintOpen(false)} />}
</> </>
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const TEMPLATES = {
blank: '',
readme: '# Project\n\nDescription.\n\n## Usage\n\n```\nnpm install\n```\n',
meeting:
'# Meeting Notes — YYYY-MM-DD\n\n## Attendees\n\n- \n## Agenda\n\n1. \n\n## Action items\n\n- [ ] \n',
blog: '# Title\n\n*Subtitle*\n\nLorem ipsum.\n\n---\n\n## Section 1\n',
};
export function FirstRunWizard() {
const firstRun = useAppStore((s) => s.firstRun);
const setFirstRun = useAppStore((s) => s.setFirstRun);
const theme = useSettingsStore((s) => s.theme);
const setSetting = useSettingsStore((s) => s.setSetting);
const updateChannel = useSettingsStore((s) => s.updateChannel);
const [step, setStep] = useState(0);
const [template, setTemplate] = useState<keyof typeof TEMPLATES>('blank');
if (!firstRun) return null;
const close = () => setFirstRun(false);
return (
<div
data-testid="first-run-wizard"
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
>
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[28rem] shadow-xl">
{step === 0 && (
<div>
<h2 className="text-lg font-semibold mb-2">Pick a theme</h2>
<div className="flex gap-2 mb-4">
{(['light', 'dark', 'system'] as const).map((t) => (
<label key={t} className="flex items-center gap-1">
<input
type="radio"
name="theme"
checked={theme === t}
onChange={() => setSetting('theme', t)}
/>
{t}
</label>
))}
</div>
</div>
)}
{step === 1 && (
<div>
<h2 className="text-lg font-semibold mb-2">Update channel</h2>
<div className="flex flex-col gap-2 mb-4">
<label className="flex items-center gap-2">
<input
type="radio"
name="channel"
checked={updateChannel === 'github'}
onChange={() => setSetting('updateChannel', 'github')}
/>
GitHub Releases (public)
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="channel"
checked={updateChannel === 'concreteinfo'}
onChange={() => setSetting('updateChannel', 'concreteinfo')}
/>
ConcreteInfo self-hosted
</label>
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="text-lg font-semibold mb-2">Starter template</h2>
<select
value={template}
onChange={(e) => setTemplate(e.target.value as any)}
className="border rounded px-2 py-1 w-full mb-4"
>
<option value="blank">Blank</option>
<option value="readme">README</option>
<option value="meeting">Meeting notes</option>
<option value="blog">Blog post</option>
</select>
</div>
)}
<div className="flex justify-between items-center mt-4">
<button onClick={close} className="text-sm text-neutral-500">
Skip
</button>
<div className="flex gap-2">
{step > 0 && <button onClick={() => setStep(step - 1)}>Back</button>}
{step < 2 ? (
<button
onClick={() => setStep(step + 1)}
className="px-3 py-1 rounded bg-brand text-white"
>
Next
</button>
) : (
<button
onClick={() => {
useAppStore.getState().newBuffer(TEMPLATES[template]);
close();
}}
className="px-3 py-1 rounded bg-brand text-white"
>
Done
</button>
)}
</div>
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useUpdaterStore } from '@/lib/updater-store';
import { ipc } from '@/lib/ipc';
import { toast } from 'sonner';
export function UpdateBanner() {
const { state, version, percent, install, check } = useUpdaterStore();
if (state === 'idle' || state === 'checking') return null;
if (state === 'error') {
return (
<div
data-testid="update-banner"
role="status"
className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm"
>
Couldn't check for updates.{' '}
<button
onClick={async () => {
try {
await check();
} catch (e: any) {
toast.error(e.message);
}
}}
className="underline"
>
Try again
</button>
</div>
);
}
if (state === 'available') {
return (
<div
data-testid="update-banner"
className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm"
>
A new version (v{version}) is available.{' '}
<button onClick={() => useUpdaterStore.getState().check()} className="underline">
Download now
</button>
</div>
);
}
if (state === 'downloading') {
return (
<div
data-testid="update-banner"
className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm"
>
Downloading update… {Math.round(percent)}%
</div>
);
}
if (state === 'ready') {
return (
<div
data-testid="update-banner"
className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm flex items-center gap-3"
>
<span>v{version} is ready.</span>
<button
onClick={() =>
ipc.app.openExternal(
`https://github.com/amitwh/markdown-converter/releases/tag/v${version}`
)
}
className="underline"
>
View release notes
</button>
<button onClick={install} className="px-3 py-1 rounded bg-brand text-white">
Restart to update
</button>
</div>
);
}
return null;
}
@@ -1,6 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { EditorState, Compartment } from '@codemirror/state'; import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
drawSelection,
} from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
@@ -10,7 +16,10 @@ import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light'; import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { setActiveView, insertSnippet } from '@/lib/editor-commands';
import { Minimap } from './Minimap'; import { Minimap } from './Minimap';
import { toast } from '@/lib/toast';
import { cn } from '@/lib/utils';
interface Props { interface Props {
bufferId: string; bufferId: string;
@@ -19,6 +28,63 @@ interface Props {
onCursorChange?: (line: number, column: number) => void; onCursorChange?: (line: number, column: number) => void;
} }
const IMAGE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp',
'image/avif',
]);
function guessExt(mimeType: string): string {
const map: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/avif': 'avif',
};
return map[mimeType] ?? 'png';
}
async function handleImageFile(file: File): Promise<void> {
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1];
if (!base64) return;
const ext = guessExt(file.type);
const result = await window.electronAPI.invoke('save-pasted-image', { base64, ext });
if (result) {
insertSnippet(`![${file.name}](${result.relativePath})`);
toast.success('Image pasted');
}
};
reader.readAsDataURL(file);
}
function createPasteHandler() {
return EditorView.domEventHandlers({
paste(event: ClipboardEvent, _view: EditorView) {
const items = event.clipboardData?.items;
if (!items) return false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (IMAGE_TYPES.has(item.type)) {
event.preventDefault();
const file = item.getAsFile();
if (file) handleImageFile(file);
return true;
}
}
return false;
},
});
}
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) { export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
@@ -27,6 +93,12 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
const updateContent = useEditorStore((s) => s.updateContent); const updateContent = useEditorStore((s) => s.updateContent);
const setCursor = useEditorStore((s) => s.setCursor); const setCursor = useEditorStore((s) => s.setCursor);
const minimap = useSettingsStore((s) => s.minimap); const minimap = useSettingsStore((s) => s.minimap);
const editorFontSize = useSettingsStore((s) => s.editorFontSize);
const buffer = useEditorStore((s) => s.buffers.get(bufferId));
const content = buffer?.content ?? initialContent;
const [scrollRatio, setScrollRatio] = useState(0);
const [visibleRatio, setVisibleRatio] = useState(1);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
@@ -40,8 +112,16 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
drawSelection(), drawSelection(),
markdown({ base: markdownLanguage, codeLanguages: [] }), markdown({ base: markdownLanguage, codeLanguages: [] }),
autocompletion(), autocompletion(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap, indentWithTab]), keymap.of([
themeCompartment.current.of(resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]), ...defaultKeymap,
...historyKeymap,
...searchKeymap,
...completionKeymap,
indentWithTab,
]),
themeCompartment.current.of(
resolvedTheme === 'dark' ? [oneDark] : [lightTheme, lightHighlight]
),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of((v) => { EditorView.updateListener.of((v) => {
if (v.docChanged) { if (v.docChanged) {
@@ -58,11 +138,28 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
onCursorChange?.(lineNo, col); onCursorChange?.(lineNo, col);
} }
}), }),
EditorView.theme({
'&': { fontSize: `${editorFontSize}px` },
}),
EditorView.domEventHandlers({
scroll(_event, view) {
const el = view.scrollDOM;
const denom = el.scrollHeight - el.clientHeight;
setScrollRatio(denom > 0 ? el.scrollTop / denom : 0);
setVisibleRatio(
el.clientHeight > 0 ? Math.min(1, el.clientHeight / el.scrollHeight) : 1
);
return false;
},
}),
createPasteHandler(),
], ],
}); });
const view = new EditorView({ state, parent: ref.current }); const view = new EditorView({ state, parent: ref.current });
viewRef.current = view; viewRef.current = view;
setActiveView(view);
return () => { return () => {
setActiveView(null);
view.destroy(); view.destroy();
viewRef.current = null; viewRef.current = null;
}; };
@@ -79,10 +176,43 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
}); });
}, [resolvedTheme]); }, [resolvedTheme]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter((f) => IMAGE_TYPES.has(f.type));
for (const file of imageFiles) {
await handleImageFile(file);
}
}, []);
return ( return (
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
<div ref={ref} className="h-full overflow-hidden" /> <div
{minimap && <Minimap content={initialContent} />} ref={ref}
className={cn(
'h-full overflow-hidden transition-colors duration-150',
isDragOver && 'ring-2 ring-primary bg-primary/5'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
/>
{minimap && (
<Minimap content={content} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
)}
</div> </div>
); );
} }
+36 -9
View File
@@ -1,15 +1,43 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor'; import { CodeMirrorEditor } from './CodeMirrorEditor';
import { FindReplaceBar } from './FindReplaceBar';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store'; import { usePreviewStore } from '@/stores/preview-store';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
export function EditorPane() { export function EditorPane() {
const { buffers, activeId } = useEditorStore(); const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null; const buf = activeId ? buffers.get(activeId) : null;
const setPreviewSource = usePreviewStore((s) => s.setSource); const setPreviewSource = usePreviewStore((s) => s.setSource);
const lastActiveId = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (buf) setPreviewSource(buf.content); if (!buf) return;
const isNewFile = lastActiveId.current !== buf.id;
lastActiveId.current = buf.id;
const isLarge = buf.content.length > 1024 * 1024;
if (isLarge) {
if (!usePreviewStore.getState().largeFileMode) {
usePreviewStore.setState({ largeFileMode: true });
useAppStore.setState({ previewVisible: false });
toast.warning(
'Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.'
);
}
// Only render on initial load / tab switch, not on edits
if (isNewFile) {
usePreviewStore.getState().forceRender(buf.content);
}
} else {
if (usePreviewStore.getState().largeFileMode) {
usePreviewStore.setState({ largeFileMode: false });
}
setPreviewSource(buf.content);
}
}, [buf?.id, buf?.content, buf, setPreviewSource]); }, [buf?.id, buf?.content, buf, setPreviewSource]);
if (!buf) { if (!buf) {
@@ -21,12 +49,11 @@ export function EditorPane() {
} }
return ( return (
<div className="h-full"> <div className="flex h-full flex-col">
<CodeMirrorEditor <FindReplaceBar />
key={buf.id} <div className="flex-1 overflow-hidden">
bufferId={buf.id} <CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} />
initialContent={buf.content} </div>
/>
</div> </div>
); );
} }
@@ -0,0 +1,268 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import {
findNext,
findPrevious,
replaceNext,
replaceAll,
closeSearchPanel,
getSearchQuery,
setSearchQuery,
} from '@codemirror/search';
import type { EditorView } from '@codemirror/view';
import { getActiveView } from '@/lib/editor-commands';
import { useAppStore } from '@/stores/app-store';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { X, ChevronDown, ChevronUp, Replace, ReplaceAll, CaseSensitive, Regex } from 'lucide-react';
export function FindReplaceBar() {
const findBarOpen = useAppStore((s) => s.findBarOpen);
const toggleFindBar = useAppStore((s) => s.toggleFindBar);
const searchRef = useRef<HTMLInputElement>(null);
const replaceRef = useRef<HTMLInputElement>(null);
const [caseSensitive, setCaseSensitive] = useState(false);
const [useRegex, setUseRegex] = useState(false);
const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null);
const updateMatchCount = useCallback(() => {
const view = getActiveView();
if (!view) {
setMatchInfo(null);
return;
}
const query = getSearchQuery(view.state);
if (!query || !query.search) {
setMatchInfo(null);
return;
}
try {
const docText = view.state.doc.toString();
const searchStr = query.regexp
? query.search
: query.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = `g${query.caseSensitive ? '' : 'i'}`;
const regex = new RegExp(searchStr, flags);
const matches = docText.match(regex);
if (!matches) {
setMatchInfo({ current: 0, total: 0 });
return;
}
const selectionHead = view.state.selection.main.head;
let currentMatch = 0;
const matchPositions: number[] = [];
let match;
while ((match = regex.exec(docText)) !== null) {
matchPositions.push(match.index);
if (match.index <= selectionHead && selectionHead <= match.index + match[0].length) {
currentMatch = matchPositions.length;
}
if (matchPositions.length > 10000) break;
}
setMatchInfo({ current: currentMatch || 1, total: matchPositions.length });
} catch {
setMatchInfo(null);
}
}, []);
const executeCommand = useCallback(
(fn: (view: EditorView) => boolean | void) => {
const view = getActiveView();
if (!view) return false;
const result = fn(view);
updateMatchCount();
view.focus();
return result;
},
[updateMatchCount]
);
const handleFindNext = useCallback(() => {
executeCommand(findNext);
}, [executeCommand]);
const handleFindPrev = useCallback(() => {
executeCommand(findPrevious);
}, [executeCommand]);
const handleReplace = useCallback(() => {
executeCommand(replaceNext);
}, [executeCommand]);
const handleReplaceAll = useCallback(() => {
executeCommand(replaceAll);
}, [executeCommand]);
const handleClose = useCallback(() => {
const view = getActiveView();
if (view) closeSearchPanel(view);
toggleFindBar();
view?.focus();
}, [toggleFindBar]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const view = getActiveView();
if (!view) return;
setSearchQuery(view, {
search: value,
caseSensitive,
regexp: useRegex,
});
},
[caseSensitive, useRegex]
);
const handleReplaceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const view = getActiveView();
if (!view) return;
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive: query.caseSensitive ?? false,
regexp: query.regexp ?? false,
replace: e.target.value,
});
}
}, []);
useEffect(() => {
if (findBarOpen) {
setTimeout(() => searchRef.current?.focus(), 50);
}
}, [findBarOpen]);
useEffect(() => {
const handler = () => {
if (!useAppStore.getState().findBarOpen) {
useAppStore.getState().toggleFindBar();
}
};
window.addEventListener('mc:find-toggle', handler);
return () => window.removeEventListener('mc:find-toggle', handler);
}, []);
if (!findBarOpen) return null;
return (
<div className="flex items-center gap-1.5 border-b border-border bg-background px-2 py-1">
<Input
ref={searchRef}
placeholder="Find..."
className="h-7 w-48 text-xs"
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.shiftKey ? handleFindPrev() : handleFindNext();
}
if (e.key === 'Escape') handleClose();
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
replaceRef.current?.focus();
}
}}
data-testid="find-input"
/>
<button
type="button"
className={cn(
'rounded p-0.5 hover:bg-accent',
caseSensitive && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setCaseSensitive((v) => {
const next = !v;
const view = getActiveView();
if (view) {
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive: next,
regexp: useRegex,
replace: query.replace,
});
}
}
return next;
});
}}
aria-label="Case sensitive"
data-testid="find-case-sensitive"
>
<CaseSensitive className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(
'rounded p-0.5 hover:bg-accent',
useRegex && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setUseRegex((v) => {
const next = !v;
const view = getActiveView();
if (view) {
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive,
regexp: next,
replace: query.replace,
});
}
}
return next;
});
}}
aria-label="Use regex"
data-testid="find-regex"
>
<Regex className="h-3.5 w-3.5" />
</button>
{matchInfo && matchInfo.total > 0 && (
<span className="min-w-[4rem] text-center text-[10px] text-muted-foreground">
{matchInfo.current}/{matchInfo.total}
</span>
)}
<Input
ref={replaceRef}
placeholder="Replace..."
className="h-7 w-48 text-xs"
onChange={handleReplaceChange}
onKeyDown={(e) => {
if (e.key === 'Enter') handleReplace();
if (e.key === 'Escape') handleClose();
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
searchRef.current?.focus();
}
}}
data-testid="replace-input"
/>
<Button size="sm" variant="ghost" onClick={handleFindPrev} aria-label="Find previous">
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleFindNext} aria-label="Find next">
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleReplace} aria-label="Replace">
<Replace className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleReplaceAll} aria-label="Replace all">
<ReplaceAll className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleClose} aria-label="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
interface Props { interface Props {
content: string; content: string;
scrollRatio?: number; // 0-1, where the viewport is scrollRatio?: number; // 0-1, where the viewport is
visibleRatio?: number; // 0-1, what fraction of content is visible visibleRatio?: number; // 0-1, what fraction of content is visible
} }
export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) { export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
@@ -30,4 +30,4 @@ export function Minimap({ content, scrollRatio = 0, visibleRatio = 1 }: Props) {
/> />
</div> </div>
); );
} }
+11 -4
View File
@@ -10,7 +10,10 @@ import { useAppStore } from '@/stores/app-store';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { useFileShortcuts } from '@/hooks/use-file-shortcuts'; import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder'; import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands'; import {
useRegisterMenuCommands,
useBridgeNativeMenu,
} from '@/lib/commands/register-menu-commands';
import { useZenMode } from '@/hooks/use-zen-mode'; import { useZenMode } from '@/hooks/use-zen-mode';
export function AppShell() { export function AppShell() {
@@ -27,7 +30,9 @@ export function AppShell() {
<main className="h-screen w-screen overflow-hidden bg-background"> <main className="h-screen w-screen overflow-hidden bg-background">
<ResizablePanelGroup <ResizablePanelGroup
direction="horizontal" direction="horizontal"
onLayout={(sizes) => setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })} onLayoutChange={(sizes) =>
setPaneSizes({ sidebar: 0, editor: sizes[0], preview: sizes[1] })
}
> >
<ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}> <ResizablePanel defaultSize={previewVisible ? 50 : 100} minSize={20}>
<section className="h-full bg-background"> <section className="h-full bg-background">
@@ -58,7 +63,9 @@ export function AppShell() {
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden">
<ResizablePanelGroup <ResizablePanelGroup
direction="horizontal" direction="horizontal"
onLayout={(sizes) => setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })} onLayoutChange={(sizes) =>
setPaneSizes({ sidebar: sizes[0], editor: sizes[1], preview: sizes[2] })
}
> >
{sidebarVisible && ( {sidebarVisible && (
<> <>
@@ -86,4 +93,4 @@ export function AppShell() {
<StatusBar /> <StatusBar />
</div> </div>
); );
} }
+14 -15
View File
@@ -1,22 +1,15 @@
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useCommandStore } from '@/stores/command-store';
function extractHeadings(content: string): { level: number; text: string }[] { import { extractHeadings } from '@/lib/headings';
const lines = content.split('\n');
const headings: { level: number; text: string }[] = [];
for (const line of lines) {
const m = line.match(/^(#{1,6})\s+(.+)$/);
if (m) headings.push({ level: m[1].length, text: m[2].trim() });
}
return headings;
}
export function Breadcrumb() { export function Breadcrumb() {
const activeTabId = useFileStore((s) => s.activeTabId); const activeTabId = useFileStore((s) => s.activeTabId);
const openTabs = useFileStore((s) => s.openTabs); const openTabs = useFileStore((s) => s.openTabs);
const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined)); const buffer = useEditorStore((s) => (activeTabId ? s.buffers.get(activeTabId) : undefined));
const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols); const showSymbols = useSettingsStore((s) => s.breadcrumbSymbols);
const dispatch = useCommandStore((s) => s.dispatch);
const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null; const tab = activeTabId ? openTabs.find((t) => t.id === activeTabId) : null;
const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : []; const headings = showSymbols && buffer ? extractHeadings(buffer.content).slice(0, 3) : [];
@@ -26,13 +19,19 @@ export function Breadcrumb() {
aria-label="File path" aria-label="File path"
className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground" className="flex h-7 items-center gap-1 border-b border-border bg-card/10 px-3 text-xs text-muted-foreground"
> >
<span>{tab ? tab.title : 'No file selected'}</span> <span className="truncate">{tab ? tab.title : 'No file selected'}</span>
{headings.map((h, i) => ( {headings.map((h, i) => (
<span key={i} className="flex items-center gap-1"> <button
key={i}
onClick={() => dispatch('editor.gotoHeading', h.line)}
className="flex items-center gap-1 hover:text-foreground"
>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span className="truncate">{'#'.repeat(h.level)} {h.text}</span> <span className="truncate">
</span> {'#'.repeat(h.level)} {h.text}
</span>
</button>
))} ))}
</nav> </nav>
); );
} }
+3 -1
View File
@@ -17,7 +17,9 @@ export function StatusBar() {
<span>UTF-8</span> <span>UTF-8</span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>Ln {cursor.line}, Col {cursor.column}</span> <span>
Ln {cursor.line}, Col {cursor.column}
</span>
<span>Markdown</span> <span>Markdown</span>
</div> </div>
</footer> </footer>
+1 -5
View File
@@ -7,11 +7,7 @@ import {
closestCenter, closestCenter,
type DragEndEvent, type DragEndEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import { SortableContext, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { useFileStore, type OpenTab } from '@/stores/file-store'; import { useFileStore, type OpenTab } from '@/stores/file-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
+67 -7
View File
@@ -1,4 +1,16 @@
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react'; import {
Bold,
Italic,
List,
ListOrdered,
Code,
Link as LinkIcon,
PanelLeft,
PanelRight,
Save,
FolderOpen,
FileText,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCommandStore } from '@/stores/command-store'; import { useCommandStore } from '@/stores/command-store';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
@@ -66,12 +78,60 @@ export function Toolbar() {
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<Button variant="ghost" size="icon" aria-label="Bold" disabled><Bold className="h-4 w-4" /></Button> <Button
<Button variant="ghost" size="icon" aria-label="Italic" disabled><Italic className="h-4 w-4" /></Button> variant="ghost"
<Button variant="ghost" size="icon" aria-label="Unordered list" disabled><List className="h-4 w-4" /></Button> size="icon"
<Button variant="ghost" size="icon" aria-label="Ordered list" disabled><ListOrdered className="h-4 w-4" /></Button> aria-label="Bold"
<Button variant="ghost" size="icon" aria-label="Code" disabled><Code className="h-4 w-4" /></Button> data-testid="toolbar-bold"
<Button variant="ghost" size="icon" aria-label="Link" disabled><LinkIcon className="h-4 w-4" /></Button> onClick={() => dispatch('editor.bold')}
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Italic"
data-testid="toolbar-italic"
onClick={() => dispatch('editor.italic')}
>
<Italic className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Unordered list"
data-testid="toolbar-list-unordered"
onClick={() => dispatch('editor.list.unordered')}
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Ordered list"
data-testid="toolbar-list-ordered"
onClick={() => dispatch('editor.list.ordered')}
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Inline code"
data-testid="toolbar-code"
onClick={() => dispatch('editor.code')}
>
<Code className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Insert link"
data-testid="toolbar-link"
onClick={() => dispatch('editor.link')}
>
<LinkIcon className="h-4 w-4" />
</Button>
</div> </div>
); );
} }
+11 -4
View File
@@ -1,5 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
@@ -17,10 +24,10 @@ export function AboutDialog() {
return ( return (
<Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}> <Dialog open={isOpen} onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="about-desc"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>About MarkdownConverter</DialogTitle> <DialogTitle>About MarkdownConverter</DialogTitle>
<DialogDescription id="about-desc"> <DialogDescription>
Professional Markdown editor and universal file converter. Professional Markdown editor and universal file converter.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -46,4 +53,4 @@ export function AboutDialog() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -18,7 +18,10 @@ export function AboutSettings() {
<a <a
href="https://github.com/amitwh/markdown-converter" href="https://github.com/amitwh/markdown-converter"
className="text-brand hover:underline" className="text-brand hover:underline"
onClick={(e) => { e.preventDefault(); ipc.app.openExternal('https://github.com/amitwh/markdown-converter'); }} onClick={(e) => {
e.preventDefault();
ipc.app.openExternal('https://github.com/amitwh/markdown-converter');
}}
> >
GitHub repository GitHub repository
</a> </a>
@@ -27,7 +30,10 @@ export function AboutSettings() {
<a <a
href="https://concreteinfo.co.in" href="https://concreteinfo.co.in"
className="text-brand hover:underline" className="text-brand hover:underline"
onClick={(e) => { e.preventDefault(); ipc.app.openExternal('https://concreteinfo.co.in'); }} onClick={(e) => {
e.preventDefault();
ipc.app.openExternal('https://concreteinfo.co.in');
}}
> >
ConcreteInfo ConcreteInfo
</a> </a>
@@ -1,8 +1,21 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
@@ -17,9 +30,15 @@ export function AsciiGeneratorDialog() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
figletText(text || ' ', font) figletText(text || ' ', font)
.then((result) => { if (!cancelled) setOutput(result); }) .then((result) => {
.catch(() => { if (!cancelled) setOutput('(render error)'); }); if (!cancelled) setOutput(result);
return () => { cancelled = true; }; })
.catch(() => {
if (!cancelled) setOutput('(render error)');
});
return () => {
cancelled = true;
};
}, [text, font]); }, [text, font]);
const handleCopy = async () => { const handleCopy = async () => {
@@ -33,10 +52,10 @@ export function AsciiGeneratorDialog() {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="ascii-desc" className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>ASCII generator</DialogTitle> <DialogTitle>ASCII generator</DialogTitle>
<DialogDescription id="ascii-desc">Type text, pick a font, see ASCII art</DialogDescription> <DialogDescription>Type text, pick a font, see ASCII art</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@@ -52,26 +71,35 @@ export function AsciiGeneratorDialog() {
<div> <div>
<Label htmlFor="ascii-font">Font</Label> <Label htmlFor="ascii-font">Font</Label>
<Select value={font} onValueChange={(v) => setFont(v as FigletFont)}> <Select value={font} onValueChange={(v) => setFont(v as FigletFont)}>
<SelectTrigger id="ascii-font" aria-label="Font"><SelectValue /></SelectTrigger> <SelectTrigger id="ascii-font" aria-label="Font">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{FIGLET_FONTS.map((f) => ( {FIGLET_FONTS.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem> <SelectItem key={f} value={f}>
{f}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<Label>Output</Label> <Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="ascii-output"> <pre
className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs"
data-testid="ascii-output"
>
{output} {output}
</pre> </pre>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button> <Button variant="ghost" onClick={closeModal}>
Close
</Button>
<Button onClick={handleCopy}>Copy</Button> <Button onClick={handleCopy}>Copy</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -0,0 +1,294 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAppStore } from '@/stores/app-store';
import { ExportDialogFooter } from './ExportDialogFooter';
import { toast } from '@/lib/toast';
type ToolKey = 'imagemagick' | 'ffmpeg';
interface FormatOption {
value: string;
label: string;
}
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
imagemagick: {
input: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'tiff', label: 'TIFF' },
{ value: 'svg', label: 'SVG' },
{ value: 'ico', label: 'ICO' },
],
output: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'pdf', label: 'PDF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'ico', label: 'ICO' },
],
},
ffmpeg: {
input: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'mov', label: 'MOV' },
],
output: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'gif', label: 'GIF' },
{ value: 'webm', label: 'WebM' },
],
},
};
const toolLabels: Record<ToolKey, string> = {
imagemagick: 'ImageMagick',
ffmpeg: 'FFmpeg',
};
export function BatchMediaConverterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [tool, setTool] = useState<ToolKey>('imagemagick');
const [fromFormat, setFromFormat] = useState('');
const [toFormat, setToFormat] = useState('');
const [inputFolder, setInputFolder] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [includeSubfolders, setIncludeSubfolders] = useState(false);
const [converting, setConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputFormats = converterFormats[tool].input;
const outputFormats = converterFormats[tool].output;
useEffect(() => {
setFromFormat('');
setToFormat('');
}, [tool]);
const handleBrowseInputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setInputFolder(result);
}
}, []);
const handleBrowseOutputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setOutputFolder(result);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const handlers: Array<() => void> = [];
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
setProgress(Math.round((data.current / data.total) * 100));
}
};
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
setConverting(false);
setProgress(100);
if (data?.error) {
setError(data.error);
toast.error(`Batch conversion failed: ${data.error}`);
} else {
toast.success('Batch conversion complete');
closeModal();
}
};
const unsubBatch =
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
const unsubComplete =
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
handlers.push(unsubBatch, unsubComplete);
return () => handlers.forEach((h) => h());
}, [closeModal]);
const handleConvert = async () => {
if (!fromFormat || !toFormat) {
setError('Select both source and target formats');
return;
}
if (!inputFolder || !outputFolder) {
setError('Select both input and output folders');
return;
}
setConverting(true);
setProgress(0);
setError(null);
try {
await window.electronAPI?.converter?.convertBatch?.({
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
includeSubfolders,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
toast.error(`Batch conversion failed: ${msg}`);
setConverting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Batch Media Converter</DialogTitle>
<DialogDescription>
Convert multiple media files between formats using ImageMagick or FFmpeg
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<Tabs value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
<TabsList>
<TabsTrigger value="imagemagick">ImageMagick</TabsTrigger>
<TabsTrigger value="ffmpeg">FFmpeg</TabsTrigger>
</TabsList>
<TabsContent value={tool} className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="batch-from">From</Label>
<Select value={fromFormat} onValueChange={setFromFormat}>
<SelectTrigger id="batch-from" aria-label="Source format">
<SelectValue placeholder="Source format" />
</SelectTrigger>
<SelectContent>
{inputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="batch-to">To</Label>
<Select value={toFormat} onValueChange={setToFormat}>
<SelectTrigger id="batch-to" aria-label="Target format">
<SelectValue placeholder="Target format" />
</SelectTrigger>
<SelectContent>
{outputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Input
value={inputFolder}
onChange={(e) => setInputFolder(e.target.value)}
placeholder="Input folder"
className="flex-1"
aria-label="Input folder"
/>
<Button variant="outline" onClick={handleBrowseInputFolder}>
Browse
</Button>
</div>
<div className="flex items-center gap-2">
<Input
value={outputFolder}
onChange={(e) => setOutputFolder(e.target.value)}
placeholder="Output folder"
className="flex-1"
aria-label="Output folder"
/>
<Button variant="outline" onClick={handleBrowseOutputFolder}>
Browse
</Button>
</div>
<div className="flex items-center gap-3">
<Switch
checked={includeSubfolders}
onCheckedChange={setIncludeSubfolders}
id="batch-subfolders"
/>
<Label htmlFor="batch-subfolders">Include subdirectories</Label>
</div>
</TabsContent>
</Tabs>
{converting && (
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.max(2, progress)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
</div>
)}
{error && (
<div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error}
</div>
)}
</div>
<ExportDialogFooter
onCancel={closeModal}
onSubmit={handleConvert}
submitting={converting}
submitLabel="Convert"
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useCommandStore } from '@/stores/command-store';
import { cn } from '@/lib/utils';
interface CommandItem {
id: string;
label: string;
shortcut: string | null;
}
const MAX_RESULTS = 8;
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands = useMemo<CommandItem[]>(() => {
const handlers = useCommandStore.getState().handlers;
return Object.keys(handlers).map((id) => ({
id,
label: id
.split('.')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' → '),
shortcut: null,
}));
}, []);
const filtered = useMemo(() => {
if (!query.trim()) return commands.slice(0, MAX_RESULTS);
const lower = query.toLowerCase();
const scored = commands
.map((cmd) => {
const labelLower = cmd.label.toLowerCase();
const idLower = cmd.id.toLowerCase();
const labelIdx = labelLower.indexOf(lower);
const idIdx = idLower.indexOf(lower);
let score = 100;
if (labelIdx === 0) score = 10;
else if (labelIdx >= 0) score = 20;
else if (idIdx === 0) score = 15;
else if (idIdx >= 0) score = 25;
else score = 100;
return { cmd, score };
})
.filter((item) => item.score < 100)
.sort((a, b) => a.score - b.score)
.map((item) => item.cmd);
return scored.slice(0, MAX_RESULTS);
}, [commands, query]);
useEffect(() => {
if (open) {
setSelectedIndex(0);
setQuery('');
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
const execute = useCallback((id: string) => {
useCommandStore.getState().dispatch(id);
setOpen(false);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) execute(cmd.id);
} else if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
}
},
[filtered, selectedIndex, execute]
);
useEffect(() => {
const handle = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, []);
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)} />
<div className="relative z-10 w-full max-w-lg rounded-lg border bg-background shadow-2xl">
<div className="border-b px-3">
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
aria-label="Search commands"
role="combobox"
aria-expanded="true"
aria-activedescendant={
filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined
}
/>
</div>
<div ref={listRef} className="max-h-64 overflow-y-auto p-1" role="listbox">
{filtered.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No matching commands
</div>
)}
{filtered.map((cmd, idx) => (
<button
key={cmd.id}
id={`cmd-${cmd.id}`}
role="option"
aria-selected={idx === selectedIndex}
className={cn(
'flex w-full items-center justify-between rounded-md px-3 py-2 text-sm',
idx === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
)}
onClick={() => execute(cmd.id)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<span>{cmd.label}</span>
{cmd.shortcut && (
<kbd className="ml-4 rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
{cmd.shortcut}
</kbd>
)}
</button>
))}
</div>
</div>
</div>
);
}
@@ -1,10 +1,25 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppStore, type ConfirmProps } from '@/stores/app-store'; import { useAppStore, type ConfirmProps } from '@/stores/app-store';
export function ConfirmDialog(props: ConfirmProps) { export function ConfirmDialog(props: ConfirmProps) {
const closeModal = useAppStore((s) => s.closeModal); const closeModal = useAppStore((s) => s.closeModal);
const { title, body, confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive, onConfirm, onCancel } = props; const {
title,
body,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
destructive,
onConfirm,
onCancel,
} = props;
const handleConfirm = async () => { const handleConfirm = async () => {
await onConfirm(); await onConfirm();
@@ -17,10 +32,10 @@ export function ConfirmDialog(props: ConfirmProps) {
return ( return (
<Dialog open onOpenChange={(o) => !o && handleCancel()}> <Dialog open onOpenChange={(o) => !o && handleCancel()}>
<DialogContent aria-describedby="confirm-body"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription id="confirm-body">{body}</DialogDescription> <DialogDescription>{body}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={handleCancel}> <Button variant="ghost" onClick={handleCancel}>
@@ -33,4 +48,4 @@ export function ConfirmDialog(props: ConfirmProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { ipc } from '@/lib/ipc';
interface Dump {
filename: string;
kind: string;
message?: string;
timestamp: string;
}
export function CrashReportModal({ onClose }: { onClose: () => void }) {
const [dumps, setDumps] = useState<Dump[]>([]);
const refresh = async () => setDumps(await ipc.crash.read());
useEffect(() => {
refresh();
}, []);
return (
<div
role="dialog"
aria-modal="true"
data-testid="crash-report-modal"
className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
>
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[36rem] max-h-[80vh] overflow-y-auto shadow-xl">
<header className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Crash reports</h2>
<button onClick={onClose} aria-label="Close">
×
</button>
</header>
<button
onClick={() => ipc.crash.openDir()}
className="mb-4 px-3 py-1 text-sm border rounded"
>
Open dump folder
</button>
{dumps.length === 0 ? (
<p data-testid="empty-state" className="text-neutral-500">
No crashes recorded nice work!
</p>
) : (
<ul className="space-y-2">
{dumps.map((d) => (
<li
key={d.filename}
className="border rounded p-2 text-sm flex justify-between items-start gap-2"
>
<div>
<div className="font-mono text-xs text-neutral-500">{d.timestamp}</div>
<div>{d.message ?? '(no message)'}</div>
</div>
<button
onClick={async () => {
await ipc.crash.delete(d.filename);
refresh();
}}
className="text-red-600 text-xs"
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -1,7 +1,13 @@
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
export function EditorSettings() { export function EditorSettings() {
@@ -11,12 +17,24 @@ export function EditorSettings() {
<div className="space-y-5 text-sm"> <div className="space-y-5 text-sm">
<div> <div>
<Label htmlFor="editor-font-size">Font size: {fontSize}px</Label> <Label htmlFor="editor-font-size">Font size: {fontSize}px</Label>
<Slider id="editor-font-size" min={10} max={24} step={1} value={[fontSize]} onValueChange={([v]) => setSetting('fontSize', v)} /> <Slider
id="editor-font-size"
min={10}
max={24}
step={1}
value={[fontSize]}
onValueChange={([v]) => setSetting('fontSize', v)}
/>
</div> </div>
<div> <div>
<Label htmlFor="editor-tab-size">Tab size</Label> <Label htmlFor="editor-tab-size">Tab size</Label>
<Select value={String(tabSize)} onValueChange={(v) => setSetting('tabSize', Number(v) as 2 | 4 | 8)}> <Select
<SelectTrigger id="editor-tab-size" aria-label="Tab size"><SelectValue /></SelectTrigger> value={String(tabSize)}
onValueChange={(v) => setSetting('tabSize', Number(v) as 2 | 4 | 8)}
>
<SelectTrigger id="editor-tab-size" aria-label="Tab size">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="2">2 spaces</SelectItem> <SelectItem value="2">2 spaces</SelectItem>
<SelectItem value="4">4 spaces</SelectItem> <SelectItem value="4">4 spaces</SelectItem>
@@ -26,15 +44,27 @@ export function EditorSettings() {
</div> </div>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span>Line numbers</span> <span>Line numbers</span>
<Switch checked={lineNumbers} onCheckedChange={(c) => setSetting('lineNumbers', c)} aria-label="Line numbers" /> <Switch
checked={lineNumbers}
onCheckedChange={(c) => setSetting('lineNumbers', c)}
aria-label="Line numbers"
/>
</label> </label>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span>Word wrap</span> <span>Word wrap</span>
<Switch checked={wordWrap} onCheckedChange={(c) => setSetting('wordWrap', c)} aria-label="Word wrap" /> <Switch
checked={wordWrap}
onCheckedChange={(c) => setSetting('wordWrap', c)}
aria-label="Word wrap"
/>
</label> </label>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span>Minimap</span> <span>Minimap</span>
<Switch checked={minimap} onCheckedChange={(c) => setSetting('minimap', c)} aria-label="Minimap" /> <Switch
checked={minimap}
onCheckedChange={(c) => setSetting('minimap', c)}
aria-label="Minimap"
/>
</label> </label>
</div> </div>
); );
@@ -1,7 +1,19 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
@@ -37,16 +49,18 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="batch-desc"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Batch export</DialogTitle> <DialogTitle>Batch export</DialogTitle>
<DialogDescription id="batch-desc">{sourcePaths.length} files</DialogDescription> <DialogDescription>{sourcePaths.length} files</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div>
<Label htmlFor="batch-format">Format</Label> <Label htmlFor="batch-format">Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as any)}> <Select value={format} onValueChange={(v) => setFormat(v as any)}>
<SelectTrigger id="batch-format" aria-label="Format"><SelectValue /></SelectTrigger> <SelectTrigger id="batch-format" aria-label="Format">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="pdf">PDF</SelectItem> <SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="docx">DOCX</SelectItem> <SelectItem value="docx">DOCX</SelectItem>
@@ -58,24 +72,40 @@ export function ExportBatchDialog({ sourcePaths }: { sourcePaths: string[] }) {
<div> <div>
<Label htmlFor="batch-concurrency">Concurrency</Label> <Label htmlFor="batch-concurrency">Concurrency</Label>
<Select value={String(concurrency)} onValueChange={(v) => setConcurrency(Number(v))}> <Select value={String(concurrency)} onValueChange={(v) => setConcurrency(Number(v))}>
<SelectTrigger id="batch-concurrency" aria-label="Concurrency"><SelectValue /></SelectTrigger> <SelectTrigger id="batch-concurrency" aria-label="Concurrency">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{[1, 2, 4, 8, 16].map((n) => ( {[1, 2, 4, 8, 16].map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem> <SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="max-h-40 overflow-auto rounded border border-border bg-card/20 p-2 text-xs"> <div className="max-h-40 overflow-auto rounded border border-border bg-card/20 p-2 text-xs">
{sourcePaths.map((p) => <div key={p} className="truncate">{p}</div>)} {sourcePaths.map((p) => (
<div key={p} className="truncate">
{p}
</div>
))}
</div> </div>
{error && ( {error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"> <div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error} {error}
</div> </div>
)} )}
</div> </div>
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" /> <ExportDialogFooter
onCancel={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
submitLabel="Export"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -9,7 +9,13 @@ interface Props {
submitDisabled?: boolean; submitDisabled?: boolean;
} }
export function ExportDialogFooter({ onCancel, onSubmit, submitting, submitLabel, submitDisabled }: Props) { export function ExportDialogFooter({
onCancel,
onSubmit,
submitting,
submitLabel,
submitDisabled,
}: Props) {
return ( return (
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={submitting}> <Button variant="ghost" onClick={onCancel} disabled={submitting}>
@@ -20,4 +26,4 @@ export function ExportDialogFooter({ onCancel, onSubmit, submitting, submitLabel
</Button> </Button>
</DialogFooter> </DialogFooter>
); );
} }
@@ -1,11 +1,26 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
import { generateDocx } from '@/lib/docx-export';
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter'; import { ExportDialogFooter } from './ExportDialogFooter';
@@ -16,41 +31,73 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
const source = useExportSource(); const source = useExportSource();
const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate); const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [referenceDoc, setReferenceDoc] = useState('');
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [bibliography, setBibliography] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!source) { setError('No file open.'); return; } if (!source) {
setError('No file open.');
return;
}
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const result = await ipc.export.docx({ try {
inputPath: source.path, const blob = await generateDocx({ source: source.source, title: source.title });
outputPath: source.path.replace(/\.md$/, '.docx'), const saveResult = await ipc.app.showSaveDialog?.({
template, title: 'Save as Word document',
renderTablesAsAscii: ascii, defaultPath: source.path.replace(/\.md$/, '.docx'),
} as any); });
if (!result.ok) { if (!saveResult?.ok || !saveResult.data) {
toast.error(`Export failed: ${result.error.message}`); setSubmitting(false);
setError(result.error.message); return;
setSubmitting(false); }
} else {
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`); const options = {
referenceDoc: referenceDoc || undefined,
toc,
tocDepth,
numberSections,
bibliography: bibliography || undefined,
};
await window.electronAPI?.export?.withOptions?.('docx', options);
const buffer = new Uint8Array(await blob.arrayBuffer());
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
if (!writeResult.ok) {
setError(writeResult.error.message);
setSubmitting(false);
return;
}
toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal(); closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
} }
}; };
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="docx-desc"> <DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to DOCX</DialogTitle> <DialogTitle>Export to DOCX</DialogTitle>
<DialogDescription id="docx-desc">{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div>
<Label htmlFor="docx-template">Template</Label> <Label htmlFor="docx-template">Template</Label>
<Select value={template} onValueChange={(v) => setTemplate(v as any)}> <Select value={template} onValueChange={(v) => setTemplate(v as any)}>
<SelectTrigger id="docx-template" aria-label="Template"><SelectValue /></SelectTrigger> <SelectTrigger id="docx-template" aria-label="Template">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="standard">Standard</SelectItem> <SelectItem value="standard">Standard</SelectItem>
<SelectItem value="minimal">Minimal</SelectItem> <SelectItem value="minimal">Minimal</SelectItem>
@@ -58,21 +105,81 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Bundled with the app. Standard is the default; Modern adds a colored cover page. The renderer-side export produces the same document for all three template choices;
the option is preserved for future stylesheets.
</p> </p>
</div> </div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" /> <Checkbox
checked={ascii}
onCheckedChange={(c) => setAscii(!!c)}
aria-label="ASCII tables"
/>
Render tables as ASCII Render tables as ASCII
</label> </label>
<div>
<Label htmlFor="docx-reference">Reference doc / template</Label>
<Input
id="docx-reference"
value={referenceDoc}
onChange={(e) => setReferenceDoc(e.target.value)}
placeholder="/path/to/reference.docx"
aria-label="Reference document path"
/>
</div>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="docx-toc" />
<Label htmlFor="docx-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="docx-toc-depth">TOC Depth</Label>
<Input
id="docx-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="docx-number-sections"
/>
<Label htmlFor="docx-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="docx-bibliography">Bibliography file</Label>
<Input
id="docx-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/references.bib"
aria-label="Bibliography file path"
/>
</div>
{error && ( {error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"> <div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error} {error}
</div> </div>
)} )}
</div> </div>
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" /> <ExportDialogFooter
onCancel={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
submitLabel="Export"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -1,11 +1,26 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
import { generateHtml } from '@/lib/html-export';
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter'; import { ExportDialogFooter } from './ExportDialogFooter';
@@ -17,46 +32,89 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
const [standalone, setStandalone] = useState(true); const [standalone, setStandalone] = useState(true);
const [highlight, setHighlight] = useState(htmlHighlightStyle); const [highlight, setHighlight] = useState(htmlHighlightStyle);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [selfContained, setSelfContained] = useState(false);
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [cssPath, setCssPath] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!source) { setError('No file open.'); return; } if (!source) {
setError('No file open.');
return;
}
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const result = await ipc.export.html({ try {
inputPath: source.path, const html = generateHtml({
outputPath: source.path.replace(/\.md$/, '.html'), source: source.source,
standalone, title: source.title,
highlightStyle: highlight, standalone,
renderTablesAsAscii: ascii, highlightStyle: highlight,
} as any); renderTablesAsAscii: ascii,
if (!result.ok) { });
toast.error(`Export failed: ${result.error.message}`);
setError(result.error.message); const options = {
setSubmitting(false); standalone,
} else { highlightStyle: highlight,
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`); selfContained,
toc,
tocDepth,
numberSections,
css: cssPath || undefined,
};
await window.electronAPI?.export?.withOptions?.('html', options);
const saveResult = await ipc.app.showSaveDialog?.({
title: 'Save as HTML',
defaultPath: source.path.replace(/\.md$/, '.html'),
});
if (!saveResult?.ok || !saveResult.data) {
setSubmitting(false);
return;
}
const buffer = new TextEncoder().encode(html);
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
if (!writeResult.ok) {
setError(writeResult.error.message);
setSubmitting(false);
return;
}
toast.success(`Exported ${source.title} to ${saveResult.data}`);
closeModal(); closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
} }
}; };
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="html-desc"> <DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to HTML</DialogTitle> <DialogTitle>Export to HTML</DialogTitle>
<DialogDescription id="html-desc">{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={standalone} onCheckedChange={(c) => setStandalone(!!c)} aria-label="Standalone" /> <Checkbox
checked={standalone}
onCheckedChange={(c) => setStandalone(!!c)}
aria-label="Standalone"
/>
Standalone document (with inline CSS) Standalone document (with inline CSS)
</label> </label>
<div> <div>
<Label htmlFor="html-highlight">Syntax highlight style</Label> <Label htmlFor="html-highlight">Syntax highlight style</Label>
<Select value={highlight} onValueChange={(v) => setHighlight(v as any)}> <Select value={highlight} onValueChange={(v) => setHighlight(v as any)}>
<SelectTrigger id="html-highlight" aria-label="Highlight style"><SelectValue /></SelectTrigger> <SelectTrigger id="html-highlight" aria-label="Highlight style">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="github">GitHub</SelectItem> <SelectItem value="github">GitHub</SelectItem>
<SelectItem value="monokai">Monokai</SelectItem> <SelectItem value="monokai">Monokai</SelectItem>
@@ -65,18 +123,75 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3">
<Switch
checked={selfContained}
onCheckedChange={setSelfContained}
id="html-self-contained"
/>
<Label htmlFor="html-self-contained">Self-contained (embed all CSS inline)</Label>
</div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" /> <Checkbox
checked={ascii}
onCheckedChange={(c) => setAscii(!!c)}
aria-label="ASCII tables"
/>
Render tables as ASCII Render tables as ASCII
</label> </label>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="html-toc" />
<Label htmlFor="html-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="html-toc-depth">TOC Depth</Label>
<Input
id="html-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="html-number-sections"
/>
<Label htmlFor="html-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="html-css">Custom CSS file</Label>
<Input
id="html-css"
value={cssPath}
onChange={(e) => setCssPath(e.target.value)}
placeholder="/path/to/custom.css"
aria-label="Custom CSS file path"
/>
</div>
{error && ( {error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"> <div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error} {error}
</div> </div>
)} )}
</div> </div>
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" /> <ExportDialogFooter
onCancel={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
submitLabel="Export"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -1,14 +1,29 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter'; import { ExportDialogFooter } from './ExportDialogFooter';
import { ipc } from '@/lib/ipc';
import { generateHtml } from '@/lib/html-export';
const MARGIN_MAP = { const MARGIN_MAP = {
normal: { top: 25, right: 25, bottom: 25, left: 25 }, normal: { top: 25, right: 25, bottom: 25, left: 25 },
@@ -18,14 +33,23 @@ const MARGIN_MAP = {
export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) { export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal); const closeModal = useAppStore((s) => s.closeModal);
const { fontSize, pdfFormat, pdfMargins, pdfEmbedFonts, renderTablesAsAscii } = useSettingsStore(); const { fontSize, pdfFormat, pdfMargins, pdfEmbedFonts, renderTablesAsAscii } =
useSettingsStore();
const [format, setFormat] = useState<'letter' | 'a4' | 'legal'>(pdfFormat); const [format, setFormat] = useState<'letter' | 'a4' | 'legal'>(pdfFormat);
const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins); const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins);
const [embed, setEmbed] = useState(pdfEmbedFonts); const [embed, setEmbed] = useState(pdfEmbedFonts);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [engine, setEngine] = useState<'pdflatex' | 'xelatex' | 'lualatex'>('pdflatex');
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [pageGeometry, setPageGeometry] = useState<'margin' | 'crop' | 'bleed'>('margin');
const [bibliography, setBibliography] = useState('');
const [mainFont, setMainFont] = useState('');
const [cjkFont, setCjkFont] = useState('');
const [highlightStyle, setHighlightStyle] = useState('tango');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const source = useExportSource(); const source = useExportSource();
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -35,37 +59,72 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
} }
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const result = await ipc.export.pdf({ try {
inputPath: source.path, const html = generateHtml({
outputPath: source.path.replace(/\.md$/, '.pdf'), source: source.source,
format, title: source.title,
margins: MARGIN_MAP[margins], standalone: true,
embedFonts: embed, highlightStyle: 'github',
renderTablesAsAscii: ascii, renderTablesAsAscii: ascii,
fontSize, });
} as any); const fmt =
if (!result.ok) { format === 'a4'
toast.error(`Export failed: ${result.error?.message ?? 'Export failed'}`); ? { width: '210mm', height: '297mm' }
setError(result.error?.message ?? 'Export failed'); : format === 'legal'
setSubmitting(false); ? { width: '8.5in', height: '14in' }
} else { : { width: '8.5in', height: '11in' };
toast.success(`Exported ${source.title} to ${result.data?.outputPath ?? 'file'}`); const m = MARGIN_MAP[margins];
const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
const finalHtml = html.replace('</style>', `${pageCss}</style>`);
const options = {
html: finalHtml,
withStyles: embed,
engine,
toc,
tocDepth,
numberSections,
pageGeometry,
bibliography: bibliography || undefined,
mainFont: mainFont || undefined,
cjkFont: cjkFont || undefined,
highlightStyle,
};
const result = await (window.electronAPI?.export?.withOptions?.('pdf', options) ??
ipc.print.show({ html: finalHtml, withStyles: embed }));
if (!result.ok) {
const msg = result.error?.message ?? 'PDF export failed';
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
return;
}
toast.success(`Sent ${source.title} to printer`);
closeModal(); closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
} }
}; };
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="pdf-desc"> <DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to PDF</DialogTitle> <DialogTitle>Export to PDF</DialogTitle>
<DialogDescription id="pdf-desc">{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div>
<Label htmlFor="pdf-format">Format</Label> <Label htmlFor="pdf-format">Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as typeof format)}> <Select value={format} onValueChange={(v) => setFormat(v as typeof format)}>
<SelectTrigger id="pdf-format" aria-label="Format"><SelectValue /></SelectTrigger> <SelectTrigger id="pdf-format" aria-label="Format">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="letter">Letter</SelectItem> <SelectItem value="letter">Letter</SelectItem>
<SelectItem value="a4">A4</SelectItem> <SelectItem value="a4">A4</SelectItem>
@@ -76,7 +135,9 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
<div> <div>
<Label htmlFor="pdf-margins">Margins</Label> <Label htmlFor="pdf-margins">Margins</Label>
<Select value={margins} onValueChange={(v) => setMargins(v as typeof margins)}> <Select value={margins} onValueChange={(v) => setMargins(v as typeof margins)}>
<SelectTrigger id="pdf-margins" aria-label="Margins"><SelectValue /></SelectTrigger> <SelectTrigger id="pdf-margins" aria-label="Margins">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="narrow">Narrow</SelectItem> <SelectItem value="narrow">Narrow</SelectItem>
<SelectItem value="normal">Normal</SelectItem> <SelectItem value="normal">Normal</SelectItem>
@@ -85,21 +146,137 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
</Select> </Select>
</div> </div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={embed} onCheckedChange={(c) => setEmbed(!!c)} aria-label="Embed fonts" /> <Checkbox
checked={embed}
onCheckedChange={(c) => setEmbed(!!c)}
aria-label="Embed fonts"
/>
Embed fonts Embed fonts
</label> </label>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" /> <Checkbox
checked={ascii}
onCheckedChange={(c) => setAscii(!!c)}
aria-label="ASCII tables"
/>
Render tables as ASCII Render tables as ASCII
</label> </label>
<div>
<Label htmlFor="pdf-engine">PDF Engine</Label>
<Select value={engine} onValueChange={(v) => setEngine(v as typeof engine)}>
<SelectTrigger id="pdf-engine" aria-label="PDF Engine">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdflatex">pdflatex</SelectItem>
<SelectItem value="xelatex">xelatex</SelectItem>
<SelectItem value="lualatex">lualatex</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="pdf-toc" />
<Label htmlFor="pdf-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="pdf-toc-depth">TOC Depth</Label>
<Input
id="pdf-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch
checked={numberSections}
onCheckedChange={setNumberSections}
id="pdf-number-sections"
/>
<Label htmlFor="pdf-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="pdf-page-geometry">Page Geometry</Label>
<Select
value={pageGeometry}
onValueChange={(v) => setPageGeometry(v as typeof pageGeometry)}
>
<SelectTrigger id="pdf-page-geometry" aria-label="Page geometry">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="margin">Margin</SelectItem>
<SelectItem value="crop">Crop</SelectItem>
<SelectItem value="bleed">Bleed</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pdf-bibliography">Bibliography file</Label>
<Input
id="pdf-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/references.bib"
aria-label="Bibliography file path"
/>
</div>
<div>
<Label htmlFor="pdf-main-font">Main font</Label>
<Input
id="pdf-main-font"
value={mainFont}
onChange={(e) => setMainFont(e.target.value)}
placeholder="e.g. Latin Modern"
aria-label="Main font"
/>
</div>
<div>
<Label htmlFor="pdf-cjk-font">CJK font</Label>
<Input
id="pdf-cjk-font"
value={cjkFont}
onChange={(e) => setCjkFont(e.target.value)}
placeholder="e.g. Noto Sans CJK SC"
aria-label="CJK font"
/>
</div>
<div>
<Label htmlFor="pdf-highlight">Highlight style</Label>
<Select value={highlightStyle} onValueChange={setHighlightStyle}>
<SelectTrigger id="pdf-highlight" aria-label="Highlight style">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tango">Tango</SelectItem>
<SelectItem value="pygments">Pygments</SelectItem>
<SelectItem value="kateks">Kate (Kateks)</SelectItem>
<SelectItem value="monochrome">Monochrome</SelectItem>
</SelectContent>
</Select>
</div>
{error && ( {error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"> <div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error} {error}
</div> </div>
)} )}
</div> </div>
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" /> <ExportDialogFooter
onCancel={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
submitLabel="Export"
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -0,0 +1,275 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store';
import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter';
export function ExportRevealjsDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal);
const source = useExportSource();
const [revealTheme, setRevealTheme] = useState('black');
const [revealTransition, setRevealTransition] = useState('slide');
const [revealTransitionSpeed, setRevealTransitionSpeed] = useState('default');
const [revealSlideNumber, setRevealSlideNumber] = useState(false);
const [revealControls, setRevealControls] = useState(true);
const [revealProgress, setRevealProgress] = useState(true);
const [revealHistory, setRevealHistory] = useState(true);
const [revealCenter, setRevealCenter] = useState(true);
const [template, setTemplate] = useState('default');
const [title, setTitle] = useState(source?.title || '');
const [author, setAuthor] = useState('');
const [date, setDate] = useState('');
const [bibliography, setBibliography] = useState('');
const [csl, setCsl] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleBrowseFile = async (setter: (v: string) => void) => {
const result = await ipc.file.pickFile();
if (result.ok && result.data) {
setter(result.data);
}
};
const handleSubmit = async () => {
if (!source) {
setError('No file open.');
return;
}
setSubmitting(true);
setError(null);
try {
const options = {
revealTheme,
revealTransition,
revealTransitionSpeed,
revealSlideNumber,
revealControls,
revealProgress,
revealHistory,
revealCenter,
template: template || undefined,
metadata: {
title: title || undefined,
author: author || undefined,
date: date || undefined,
},
bibliography: bibliography || undefined,
csl: csl || undefined,
};
await window.electronAPI?.export?.withOptions?.('revealjs', options);
toast.success('Slide export process completed');
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>Export Reveal.js Slides</DialogTitle>
<DialogDescription>{sourcePath}</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
{/* Theme & Transitions */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="reveal-theme">Theme</Label>
<Select value={revealTheme} onValueChange={setRevealTheme}>
<SelectTrigger id="reveal-theme">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['black', 'white', 'league', 'beige', 'sky', 'night', 'serif', 'simple', 'solarized', 'blood', 'moon'].map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="reveal-transition">Transition</Label>
<Select value={revealTransition} onValueChange={setRevealTransition}>
<SelectTrigger id="reveal-transition">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['slide', 'none', 'fade', 'convex', 'concave', 'zoom'].map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="reveal-speed">Transition Speed</Label>
<Select value={revealTransitionSpeed} onValueChange={setRevealTransitionSpeed}>
<SelectTrigger id="reveal-speed">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['default', 'fast', 'slow'].map((s) => (
<SelectItem key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggle Switches */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 py-2">
<div className="flex items-center gap-2">
<Switch checked={revealSlideNumber} onCheckedChange={setRevealSlideNumber} id="reveal-slide-number" />
<Label htmlFor="reveal-slide-number">Slide Numbers</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealControls} onCheckedChange={setRevealControls} id="reveal-controls" />
<Label htmlFor="reveal-controls">Controls</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealProgress} onCheckedChange={setRevealProgress} id="reveal-progress" />
<Label htmlFor="reveal-progress">Progress Bar</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealHistory} onCheckedChange={setRevealHistory} id="reveal-history" />
<Label htmlFor="reveal-history">Slide History</Label>
</div>
<div className="flex items-center gap-2 col-span-2">
<Switch checked={revealCenter} onCheckedChange={setRevealCenter} id="reveal-center" />
<Label htmlFor="reveal-center">Center Content Vertically</Label>
</div>
</div>
{/* Template & Metadata */}
<div className="border-t pt-3 space-y-3">
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Metadata & Template</Label>
<div>
<Label htmlFor="reveal-template">Template File</Label>
<div className="flex gap-2">
<Input
id="reveal-template"
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="default"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setTemplate)}>
Browse
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="reveal-title">Title</Label>
<Input id="reveal-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<Label htmlFor="reveal-author">Author</Label>
<Input id="reveal-author" value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<div>
<Label htmlFor="reveal-date">Date</Label>
<Input id="reveal-date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</div>
</div>
{/* Bibliography & CSL */}
<div className="border-t pt-3 space-y-3">
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Bibliography (Pandoc)</Label>
<div>
<Label htmlFor="reveal-bibliography">Bibliography (.bib, .json)</Label>
<div className="flex gap-2">
<Input
id="reveal-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/citations.bib"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setBibliography)}>
Browse
</Button>
</div>
</div>
<div>
<Label htmlFor="reveal-csl">CSL Style</Label>
<div className="flex gap-2">
<Input
id="reveal-csl"
value={csl}
onChange={(e) => setCsl(e.target.value)}
placeholder="/path/to/style.csl"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setCsl)}>
Browse
</Button>
</div>
</div>
</div>
{error && (
<div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error}
</div>
)}
</div>
<ExportDialogFooter
onCancel={closeModal}
onSubmit={handleSubmit}
submitting={submitting}
submitLabel="Export"
/>
</DialogContent>
</Dialog>
);
}
@@ -1,17 +1,33 @@
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
export function ExportSettings() { export function ExportSettings() {
const { pdfFormat, pdfMargins, pdfEmbedFonts, docxTemplate, htmlHighlightStyle, renderTablesAsAscii, setSetting } = useSettingsStore(); const {
pdfFormat,
pdfMargins,
pdfEmbedFonts,
docxTemplate,
htmlHighlightStyle,
renderTablesAsAscii,
setSetting,
} = useSettingsStore();
return ( return (
<div className="space-y-5 text-sm"> <div className="space-y-5 text-sm">
<div> <div>
<Label htmlFor="export-pdf-format">Default PDF format</Label> <Label htmlFor="export-pdf-format">Default PDF format</Label>
<Select value={pdfFormat} onValueChange={(v) => setSetting('pdfFormat', v as any)}> <Select value={pdfFormat} onValueChange={(v) => setSetting('pdfFormat', v as any)}>
<SelectTrigger id="export-pdf-format" aria-label="Default PDF format"><SelectValue /></SelectTrigger> <SelectTrigger id="export-pdf-format" aria-label="Default PDF format">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="letter">Letter</SelectItem> <SelectItem value="letter">Letter</SelectItem>
<SelectItem value="a4">A4</SelectItem> <SelectItem value="a4">A4</SelectItem>
@@ -22,7 +38,9 @@ export function ExportSettings() {
<div> <div>
<Label htmlFor="export-pdf-margins">Default PDF margins</Label> <Label htmlFor="export-pdf-margins">Default PDF margins</Label>
<Select value={pdfMargins} onValueChange={(v) => setSetting('pdfMargins', v as any)}> <Select value={pdfMargins} onValueChange={(v) => setSetting('pdfMargins', v as any)}>
<SelectTrigger id="export-pdf-margins" aria-label="Default PDF margins"><SelectValue /></SelectTrigger> <SelectTrigger id="export-pdf-margins" aria-label="Default PDF margins">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="narrow">Narrow</SelectItem> <SelectItem value="narrow">Narrow</SelectItem>
<SelectItem value="normal">Normal</SelectItem> <SelectItem value="normal">Normal</SelectItem>
@@ -32,12 +50,18 @@ export function ExportSettings() {
</div> </div>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span>Embed fonts in PDFs</span> <span>Embed fonts in PDFs</span>
<Switch checked={pdfEmbedFonts} onCheckedChange={(c) => setSetting('pdfEmbedFonts', c)} aria-label="Embed fonts" /> <Switch
checked={pdfEmbedFonts}
onCheckedChange={(c) => setSetting('pdfEmbedFonts', c)}
aria-label="Embed fonts"
/>
</label> </label>
<div> <div>
<Label htmlFor="export-docx-template">Default DOCX template</Label> <Label htmlFor="export-docx-template">Default DOCX template</Label>
<Select value={docxTemplate} onValueChange={(v) => setSetting('docxTemplate', v as any)}> <Select value={docxTemplate} onValueChange={(v) => setSetting('docxTemplate', v as any)}>
<SelectTrigger id="export-docx-template" aria-label="Default DOCX template"><SelectValue /></SelectTrigger> <SelectTrigger id="export-docx-template" aria-label="Default DOCX template">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="standard">Standard</SelectItem> <SelectItem value="standard">Standard</SelectItem>
<SelectItem value="minimal">Minimal</SelectItem> <SelectItem value="minimal">Minimal</SelectItem>
@@ -47,8 +71,13 @@ export function ExportSettings() {
</div> </div>
<div> <div>
<Label htmlFor="export-html-highlight">Default HTML highlight</Label> <Label htmlFor="export-html-highlight">Default HTML highlight</Label>
<Select value={htmlHighlightStyle} onValueChange={(v) => setSetting('htmlHighlightStyle', v as any)}> <Select
<SelectTrigger id="export-html-highlight" aria-label="Default HTML highlight"><SelectValue /></SelectTrigger> value={htmlHighlightStyle}
onValueChange={(v) => setSetting('htmlHighlightStyle', v as any)}
>
<SelectTrigger id="export-html-highlight" aria-label="Default HTML highlight">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="github">GitHub</SelectItem> <SelectItem value="github">GitHub</SelectItem>
<SelectItem value="monokai">Monokai</SelectItem> <SelectItem value="monokai">Monokai</SelectItem>
@@ -59,8 +88,12 @@ export function ExportSettings() {
</div> </div>
<label className="flex items-center justify-between"> <label className="flex items-center justify-between">
<span>Render tables as ASCII by default</span> <span>Render tables as ASCII by default</span>
<Switch checked={renderTablesAsAscii} onCheckedChange={(c) => setSetting('renderTablesAsAscii', c)} aria-label="ASCII tables by default" /> <Switch
checked={renderTablesAsAscii}
onCheckedChange={(c) => setSetting('renderTablesAsAscii', c)}
aria-label="ASCII tables by default"
/>
</label> </label>
</div> </div>
); );
} }
@@ -1,5 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -47,10 +54,10 @@ export function FindInFilesDialog() {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="find-desc" className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Find in files</DialogTitle> <DialogTitle>Find in files</DialogTitle>
<DialogDescription id="find-desc"> <DialogDescription>
{rootPath ? `Search in ${rootPath}` : 'No folder open'} {rootPath ? `Search in ${rootPath}` : 'No folder open'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -67,22 +74,35 @@ export function FindInFilesDialog() {
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={isRegex} onCheckedChange={(c) => setIsRegex(!!c)} aria-label="Regex" /> <Checkbox
checked={isRegex}
onCheckedChange={(c) => setIsRegex(!!c)}
aria-label="Regex"
/>
Regex Regex
</label> </label>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={caseSensitive} onCheckedChange={(c) => setCaseSensitive(!!c)} aria-label="Case sensitive" /> <Checkbox
checked={caseSensitive}
onCheckedChange={(c) => setCaseSensitive(!!c)}
aria-label="Case sensitive"
/>
Case sensitive Case sensitive
</label> </label>
</div> </div>
{error && ( {error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"> <div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error} {error}
</div> </div>
)} )}
{results.length > 0 && ( {results.length > 0 && (
<div> <div>
<Label>{results.length} result{results.length === 1 ? '' : 's'}</Label> <Label>
{results.length} result{results.length === 1 ? '' : 's'}
</Label>
<div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs"> <div className="max-h-64 overflow-auto rounded border border-border bg-card/20 text-xs">
{results.map((r, i) => ( {results.map((r, i) => (
<button <button
@@ -91,7 +111,9 @@ export function FindInFilesDialog() {
className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50" className="block w-full truncate border-b border-border/30 px-2 py-1 text-left hover:bg-card/50"
data-testid="find-result" data-testid="find-result"
> >
<span className="font-mono text-muted-foreground">{r.filePath}:{r.line}</span> <span className="font-mono text-muted-foreground">
{r.filePath}:{r.line}
</span>
<span className="ml-2">{r.content}</span> <span className="ml-2">{r.content}</span>
</button> </button>
))} ))}
@@ -100,7 +122,9 @@ export function FindInFilesDialog() {
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={closeModal} disabled={submitting}>Close</Button> <Button variant="ghost" onClick={closeModal} disabled={submitting}>
Close
</Button>
<Button onClick={handleSearch} disabled={submitting || !query}> <Button onClick={handleSearch} disabled={submitting || !query}>
{submitting ? 'Searching…' : 'Search'} {submitting ? 'Searching…' : 'Search'}
</Button> </Button>
@@ -0,0 +1,311 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
interface HeaderFooterSettings {
headerEnabled: boolean;
footerEnabled: boolean;
headerLeft: string;
headerCenter: string;
headerRight: string;
footerLeft: string;
footerCenter: string;
footerRight: string;
logoPosition: 'none' | 'left' | 'right';
logoPath: string | null;
}
const DYNAMIC_FIELDS = [
{ token: '$PAGE$', label: 'Page' },
{ token: '$TOTAL$', label: 'Total Pages' },
{ token: '$DATE$', label: 'Date' },
{ token: '$TIME$', label: 'Time' },
{ token: '$TITLE$', label: 'Title' },
{ token: '$AUTHOR$', label: 'Author' },
{ token: '$FILENAME$', label: 'Filename' },
] as const;
const DEFAULTS: HeaderFooterSettings = {
headerEnabled: false,
footerEnabled: false,
headerLeft: '',
headerCenter: '',
headerRight: '',
footerLeft: '',
footerCenter: '',
footerRight: '',
logoPosition: 'none',
logoPath: null,
};
type FieldKey = keyof Pick<
HeaderFooterSettings,
'headerLeft' | 'headerCenter' | 'headerRight' | 'footerLeft' | 'footerCenter' | 'footerRight'
>;
export function HeaderFooterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [settings, setSettings] = useState<HeaderFooterSettings>(DEFAULTS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let mounted = true;
async function load() {
try {
window.electronAPI?.send('get-header-footer-settings');
const unsub = window.electronAPI?.on('header-footer-settings-data', (data: unknown) => {
if (mounted && data && typeof data === 'object') {
setSettings({ ...DEFAULTS, ...(data as Partial<HeaderFooterSettings>) });
}
});
return () => {
unsub?.();
};
} finally {
if (mounted) setLoading(false);
}
}
void load();
return () => {
mounted = false;
};
}, []);
const updateField = useCallback((key: FieldKey, value: string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const insertToken = useCallback((key: FieldKey, token: string) => {
setSettings((prev) => ({
...prev,
[key]: prev[key] + token,
}));
}, []);
const handleBrowseLogo = useCallback(async () => {
window.electronAPI?.send('browse-header-footer-logo');
const unsub = window.electronAPI?.on('header-footer-logo-selected', (data: unknown) => {
if (typeof data === 'string') {
setSettings((prev) => ({
...prev,
logoPath: data,
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
}));
}
});
return () => {
unsub?.();
};
}, []);
const handleClearLogo = useCallback(() => {
setSettings((prev) => ({ ...prev, logoPath: null, logoPosition: 'none' }));
}, []);
const handleSave = async () => {
setSaving(true);
window.electronAPI?.send('save-header-footer-settings', settings);
const unsub = window.electronAPI?.on('header-footer-logo-saved', () => {
toast.success('Header & footer settings saved');
closeModal();
setSaving(false);
unsub?.();
});
setTimeout(() => {
setSaving(false);
toast.success('Header & footer settings saved');
closeModal();
}, 500);
};
const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [
{ key: 'headerLeft', label: 'Left', enabled: settings.headerEnabled },
{ key: 'headerCenter', label: 'Center', enabled: settings.headerEnabled },
{ key: 'headerRight', label: 'Right', enabled: settings.headerEnabled },
{ key: 'footerLeft', label: 'Left', enabled: settings.footerEnabled },
{ key: 'footerCenter', label: 'Center', enabled: settings.footerEnabled },
{ key: 'footerRight', label: 'Right', enabled: settings.footerEnabled },
];
if (loading) return null;
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Header & Footer</DialogTitle>
<DialogDescription>
Configure headers and footers for exported documents
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<label className="flex items-center gap-2">
<Checkbox
checked={settings.headerEnabled}
onCheckedChange={(c) => setSettings((p) => ({ ...p, headerEnabled: !!c }))}
aria-label="Enable header"
/>
Enable header
</label>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Header</p>
{fieldRows
.filter((r) => r.key.startsWith('header'))
.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
<Input
value={settings[row.key]}
onChange={(e) => updateField(row.key, e.target.value)}
placeholder={`Header ${row.label.toLowerCase()}`}
className="flex-1"
disabled={!settings.headerEnabled}
aria-label={`Header ${row.label.toLowerCase()}`}
/>
<div className="flex gap-0.5">
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
<Button
key={field.token}
variant="ghost"
size="sm"
className="h-7 px-1.5 text-[10px]"
disabled={!settings.headerEnabled}
onClick={() => insertToken(row.key, field.token)}
title={field.label}
>
{field.token}
</Button>
))}
</div>
</div>
))}
</div>
<label className="flex items-center gap-2">
<Checkbox
checked={settings.footerEnabled}
onCheckedChange={(c) => setSettings((p) => ({ ...p, footerEnabled: !!c }))}
aria-label="Enable footer"
/>
Enable footer
</label>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Footer</p>
{fieldRows
.filter((r) => r.key.startsWith('footer'))
.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
<Input
value={settings[row.key]}
onChange={(e) => updateField(row.key, e.target.value)}
placeholder={`Footer ${row.label.toLowerCase()}`}
className="flex-1"
disabled={!settings.footerEnabled}
aria-label={`Footer ${row.label.toLowerCase()}`}
/>
<div className="flex gap-0.5">
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
<Button
key={field.token}
variant="ghost"
size="sm"
className="h-7 px-1.5 text-[10px]"
disabled={!settings.footerEnabled}
onClick={() => insertToken(row.key, field.token)}
title={field.label}
>
{field.token}
</Button>
))}
</div>
</div>
))}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Logo</p>
<div className="flex items-center gap-2">
<Select
value={settings.logoPosition}
onValueChange={(v) =>
setSettings((p) => ({
...p,
logoPosition: v as HeaderFooterSettings['logoPosition'],
}))
}
>
<SelectTrigger className="w-32" aria-label="Logo position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
{settings.logoPosition !== 'none' && (
<>
<Input
value={settings.logoPath ?? ''}
readOnly
placeholder="No logo selected"
className="flex-1"
aria-label="Logo path"
/>
<Button variant="outline" size="sm" onClick={handleBrowseLogo}>
Browse
</Button>
{settings.logoPath && (
<Button variant="ghost" size="sm" onClick={handleClearLogo}>
Clear
</Button>
)}
</>
)}
</div>
{settings.logoPath && (
<img
src={`file://${settings.logoPath}`}
alt="Logo preview"
className="h-8 rounded border object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeModal} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+27 -1
View File
@@ -1,16 +1,23 @@
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { AboutDialog } from './AboutDialog'; import { AboutDialog } from './AboutDialog';
import { BatchMediaConverterDialog } from './BatchMediaConverterDialog';
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog'; import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
import { CrashReportModal } from './CrashReportModal';
import { ExportBatchDialog } from './ExportBatchDialog'; import { ExportBatchDialog } from './ExportBatchDialog';
import { ExportDocxDialog } from './ExportDocxDialog'; import { ExportDocxDialog } from './ExportDocxDialog';
import { ExportHtmlDialog } from './ExportHtmlDialog'; import { ExportHtmlDialog } from './ExportHtmlDialog';
import { ExportPdfDialog } from './ExportPdfDialog'; import { ExportPdfDialog } from './ExportPdfDialog';
import { ExportRevealjsDialog } from './ExportRevealjsDialog';
import { FindInFilesDialog } from './FindInFilesDialog'; import { FindInFilesDialog } from './FindInFilesDialog';
import { HeaderFooterDialog } from './HeaderFooterDialog';
import { SettingsSheet } from './SettingsSheet'; import { SettingsSheet } from './SettingsSheet';
import { TableGeneratorDialog } from './TableGeneratorDialog'; import { TableGeneratorDialog } from './TableGeneratorDialog';
import { UniversalConverterDialog } from './UniversalConverterDialog';
import { WelcomeDialog } from './WelcomeDialog'; import { WelcomeDialog } from './WelcomeDialog';
import { WordExportDialog } from './WordExportDialog'; import { WordExportDialog } from './WordExportDialog';
import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
import { PdfEditorDialog } from './PdfEditorDialog';
export function ModalLayer() { export function ModalLayer() {
const modal = useAppStore((s) => s.modal); const modal = useAppStore((s) => s.modal);
@@ -23,6 +30,8 @@ export function ModalLayer() {
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />; return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
case 'export-html': case 'export-html':
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />; return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
case 'export-revealjs':
return <ExportRevealjsDialog sourcePath={modal.props.sourcePath} />;
case 'export-batch': case 'export-batch':
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />; return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
case 'settings': case 'settings':
@@ -41,5 +50,22 @@ export function ModalLayer() {
return <TableGeneratorDialog />; return <TableGeneratorDialog />;
case 'find-in-files': case 'find-in-files':
return <FindInFilesDialog />; return <FindInFilesDialog />;
case 'crashReports':
return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
case 'writing-analytics':
return <WritingAnalyticsDialog />;
case 'pdf-editor':
return (
<PdfEditorDialog
onClose={useAppStore.getState().closeModal}
initialFilePath={modal.props?.filePath}
/>
);
case 'universal-converter':
return <UniversalConverterDialog />;
case 'header-footer':
return <HeaderFooterDialog />;
case 'batch-media-converter':
return <BatchMediaConverterDialog />;
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,10 @@
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
@@ -8,6 +14,7 @@ import { ThemeSettings } from './ThemeSettings';
import { ExportSettings } from './ExportSettings'; import { ExportSettings } from './ExportSettings';
import { PluginsSettings } from './PluginsSettings'; import { PluginsSettings } from './PluginsSettings';
import { AboutSettings } from './AboutSettings'; import { AboutSettings } from './AboutSettings';
import { UpdatesSettings } from './UpdatesSettings';
export function SettingsSheet() { export function SettingsSheet() {
const closeModal = useAppStore((s) => s.closeModal); const closeModal = useAppStore((s) => s.closeModal);
@@ -15,31 +22,47 @@ export function SettingsSheet() {
return ( return (
<Sheet open onOpenChange={(o) => !o && closeModal()}> <Sheet open onOpenChange={(o) => !o && closeModal()}>
<SheetContent aria-describedby="settings-desc" side="right" className="w-full sm:max-w-[480px]"> <SheetContent side="right" className="w-full sm:max-w-[480px]">
<SheetHeader> <SheetHeader>
<SheetTitle>Settings</SheetTitle> <SheetTitle>Settings</SheetTitle>
<SheetDescription id="settings-desc">Editor, theme, and export preferences</SheetDescription> <SheetDescription>Editor, theme, and export preferences</SheetDescription>
</SheetHeader> </SheetHeader>
<Tabs defaultValue="editor" className="mt-4"> <Tabs defaultValue="editor" className="mt-4">
<TabsList className="grid w-full grid-cols-5"> <TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="editor">Editor</TabsTrigger> <TabsTrigger value="editor">Editor</TabsTrigger>
<TabsTrigger value="theme">Theme</TabsTrigger> <TabsTrigger value="theme">Theme</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger> <TabsTrigger value="export">Export</TabsTrigger>
<TabsTrigger value="plugins">Plugins</TabsTrigger> <TabsTrigger value="plugins">Plugins</TabsTrigger>
<TabsTrigger value="updates">Updates</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger> <TabsTrigger value="about">About</TabsTrigger>
</TabsList> </TabsList>
<div className="mt-4 max-h-[70vh] overflow-y-auto pr-2"> <div className="mt-4 max-h-[70vh] overflow-y-auto pr-2">
<TabsContent value="editor"><EditorSettings /></TabsContent> <TabsContent value="editor">
<TabsContent value="theme"><ThemeSettings /></TabsContent> <EditorSettings />
<TabsContent value="export"><ExportSettings /></TabsContent> </TabsContent>
<TabsContent value="plugins"><PluginsSettings /></TabsContent> <TabsContent value="theme">
<TabsContent value="about"><AboutSettings /></TabsContent> <ThemeSettings />
</TabsContent>
<TabsContent value="export">
<ExportSettings />
</TabsContent>
<TabsContent value="plugins">
<PluginsSettings />
</TabsContent>
<TabsContent value="updates">
<UpdatesSettings />
</TabsContent>
<TabsContent value="about">
<AboutSettings />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
<div className="mt-4 flex justify-end border-t border-border pt-4"> <div className="mt-4 flex justify-end border-t border-border pt-4">
<Button variant="ghost" onClick={resetToDefaults}>Reset to defaults</Button> <Button variant="ghost" onClick={resetToDefaults}>
Reset to defaults
</Button>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
} }
@@ -1,5 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -11,7 +18,13 @@ function pad(s: string, w: number) {
return s.padEnd(w); return s.padEnd(w);
} }
function generateTable(rows: number, cols: number, hasHeader: boolean, headers: string[], data: string[][]): string { function generateTable(
rows: number,
cols: number,
hasHeader: boolean,
headers: string[],
data: string[][]
): string {
const width = cols; const width = cols;
const colWidths: number[] = Array.from({ length: width }, (_, c) => { const colWidths: number[] = Array.from({ length: width }, (_, c) => {
const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')]; const all = [headers[c] ?? '', ...data.map((r) => r[c] ?? '')];
@@ -19,11 +32,21 @@ function generateTable(rows: number, cols: number, hasHeader: boolean, headers:
}); });
const lines: string[] = []; const lines: string[] = [];
if (hasHeader) { if (hasHeader) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])).join(' | ') + ' |'); lines.push(
'| ' +
Array.from({ length: width }, (_, c) =>
pad(headers[c] ?? `Col ${c + 1}`, colWidths[c])
).join(' | ') +
' |'
);
lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |'); lines.push('| ' + colWidths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
} }
for (const row of data) { for (const row of data) {
lines.push('| ' + Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') + ' |'); lines.push(
'| ' +
Array.from({ length: width }, (_, c) => pad(row[c] ?? '', colWidths[c])).join(' | ') +
' |'
);
} }
// Add empty rows if data has fewer // Add empty rows if data has fewer
for (let i = data.length; i < rows; i++) { for (let i = data.length; i < rows; i++) {
@@ -53,17 +76,23 @@ export function TableGeneratorDialog() {
const updateHeadersCount = (newCols: number) => { const updateHeadersCount = (newCols: number) => {
setCols(newCols); setCols(newCols);
setHeaders((prev) => { setHeaders((prev) => {
if (prev.length < newCols) return [...prev, ...Array(newCols - prev.length).fill('').map((_, i) => `Col ${prev.length + i + 1}`)]; if (prev.length < newCols)
return [
...prev,
...Array(newCols - prev.length)
.fill('')
.map((_, i) => `Col ${prev.length + i + 1}`),
];
return prev.slice(0, newCols); return prev.slice(0, newCols);
}); });
}; };
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="table-desc" className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Table generator</DialogTitle> <DialogTitle>Table generator</DialogTitle>
<DialogDescription id="table-desc">Specify rows × columns to generate a markdown table</DialogDescription> <DialogDescription>Specify rows × columns to generate a markdown table</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -88,18 +117,27 @@ export function TableGeneratorDialog() {
min={1} min={1}
max={20} max={20}
value={cols} value={cols}
onChange={(e) => updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))} onChange={(e) =>
updateHeadersCount(Math.max(1, Math.min(20, Number(e.target.value) || 1)))
}
/> />
</div> </div>
</div> </div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox checked={hasHeader} onCheckedChange={(c) => setHasHeader(!!c)} aria-label="Header" /> <Checkbox
checked={hasHeader}
onCheckedChange={(c) => setHasHeader(!!c)}
aria-label="Header"
/>
Include header row Include header row
</label> </label>
{hasHeader && ( {hasHeader && (
<div> <div>
<Label>Header names</Label> <Label>Header names</Label>
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}> <div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
>
{Array.from({ length: cols }, (_, i) => ( {Array.from({ length: cols }, (_, i) => (
<Input <Input
key={i} key={i}
@@ -117,16 +155,21 @@ export function TableGeneratorDialog() {
)} )}
<div> <div>
<Label>Output</Label> <Label>Output</Label>
<pre className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs" data-testid="table-output"> <pre
className="overflow-auto rounded border border-border bg-card/30 p-3 text-xs"
data-testid="table-output"
>
{output} {output}
</pre> </pre>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={closeModal}>Close</Button> <Button variant="ghost" onClick={closeModal}>
Close
</Button>
<Button onClick={handleCopy}>Copy</Button> <Button onClick={handleCopy}>Copy</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }
@@ -1,7 +1,13 @@
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export function ThemeSettings() { export function ThemeSettings() {
const { theme, accentColor, fontFamily, setSetting } = useSettingsStore(); const { theme, accentColor, fontFamily, setSetting } = useSettingsStore();
@@ -10,16 +16,30 @@ export function ThemeSettings() {
<div className="space-y-5 text-sm"> <div className="space-y-5 text-sm">
<div> <div>
<Label>Mode</Label> <Label>Mode</Label>
<RadioGroup value={theme} onValueChange={(v) => setSetting('theme', v as 'light' | 'dark' | 'auto')}> <RadioGroup
<div className="flex items-center gap-2"><RadioGroupItem value="light" id="theme-light" /><Label htmlFor="theme-light">Light</Label></div> value={theme}
<div className="flex items-center gap-2"><RadioGroupItem value="dark" id="theme-dark" /><Label htmlFor="theme-dark">Dark</Label></div> onValueChange={(v) => setSetting('theme', v as 'light' | 'dark' | 'system')}
<div className="flex items-center gap-2"><RadioGroupItem value="auto" id="theme-auto" /><Label htmlFor="theme-auto">Auto (system)</Label></div> >
<div className="flex items-center gap-2">
<RadioGroupItem value="light" id="theme-light" />
<Label htmlFor="theme-light">Light</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="dark" id="theme-dark" />
<Label htmlFor="theme-dark">Dark</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="system" id="theme-system" />
<Label htmlFor="theme-system">System</Label>
</div>
</RadioGroup> </RadioGroup>
</div> </div>
<div> <div>
<Label htmlFor="theme-accent">Accent color</Label> <Label htmlFor="theme-accent">Accent color</Label>
<Select value={accentColor} onValueChange={(v) => setSetting('accentColor', v as any)}> <Select value={accentColor} onValueChange={(v) => setSetting('accentColor', v as any)}>
<SelectTrigger id="theme-accent" aria-label="Accent color"><SelectValue /></SelectTrigger> <SelectTrigger id="theme-accent" aria-label="Accent color">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="brand">Brand (orange)</SelectItem> <SelectItem value="brand">Brand (orange)</SelectItem>
<SelectItem value="blue">Blue</SelectItem> <SelectItem value="blue">Blue</SelectItem>
@@ -32,7 +52,9 @@ export function ThemeSettings() {
<div> <div>
<Label htmlFor="theme-font-family">Editor font</Label> <Label htmlFor="theme-font-family">Editor font</Label>
<Select value={fontFamily} onValueChange={(v) => setSetting('fontFamily', v as any)}> <Select value={fontFamily} onValueChange={(v) => setSetting('fontFamily', v as any)}>
<SelectTrigger id="theme-font-family" aria-label="Editor font"><SelectValue /></SelectTrigger> <SelectTrigger id="theme-font-family" aria-label="Editor font">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="system">System (Plus Jakarta Sans)</SelectItem> <SelectItem value="system">System (Plus Jakarta Sans)</SelectItem>
<SelectItem value="jetbrains">JetBrains Mono</SelectItem> <SelectItem value="jetbrains">JetBrains Mono</SelectItem>
@@ -42,4 +64,4 @@ export function ThemeSettings() {
</div> </div>
</div> </div>
); );
} }
@@ -0,0 +1,386 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { ExportDialogFooter } from './ExportDialogFooter';
import { toast } from '@/lib/toast';
type ToolKey = 'imagemagick' | 'ffmpeg' | 'libreoffice' | 'pandoc';
interface FormatOption {
value: string;
label: string;
}
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
imagemagick: {
input: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'tiff', label: 'TIFF' },
{ value: 'svg', label: 'SVG' },
{ value: 'ico', label: 'ICO' },
],
output: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'pdf', label: 'PDF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'ico', label: 'ICO' },
],
},
ffmpeg: {
input: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'mov', label: 'MOV' },
],
output: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'gif', label: 'GIF' },
{ value: 'webm', label: 'WebM' },
],
},
libreoffice: {
input: [
{ value: 'docx', label: 'DOCX' },
{ value: 'xlsx', label: 'XLSX' },
{ value: 'pptx', label: 'PPTX' },
{ value: 'odt', label: 'ODT' },
{ value: 'pdf', label: 'PDF' },
{ value: 'html', label: 'HTML' },
],
output: [
{ value: 'pdf', label: 'PDF' },
{ value: 'docx', label: 'DOCX' },
{ value: 'xlsx', label: 'XLSX' },
{ value: 'pptx', label: 'PPTX' },
{ value: 'odt', label: 'ODT' },
{ value: 'html', label: 'HTML' },
{ value: 'txt', label: 'Text' },
],
},
pandoc: {
input: [
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
{ value: 'docx', label: 'DOCX' },
{ value: 'latex', label: 'LaTeX' },
{ value: 'rst', label: 'reStructuredText' },
{ value: 'epub', label: 'EPUB' },
{ value: 'org', label: 'Org Mode' },
{ value: 'wiki', label: 'MediaWiki' },
],
output: [
{ value: 'pdf', label: 'PDF' },
{ value: 'docx', label: 'DOCX' },
{ value: 'html', label: 'HTML' },
{ value: 'latex', label: 'LaTeX' },
{ value: 'epub', label: 'EPUB' },
{ value: 'rst', label: 'reStructuredText' },
{ value: 'org', label: 'Org Mode' },
{ value: 'plain', label: 'Plain Text' },
{ value: 'rtf', label: 'RTF' },
{ value: 'odt', label: 'ODT' },
{ value: 'wiki', label: 'MediaWiki' },
],
},
};
const toolLabels: Record<ToolKey, string> = {
imagemagick: 'ImageMagick',
ffmpeg: 'FFmpeg',
libreoffice: 'LibreOffice',
pandoc: 'Pandoc',
};
export function UniversalConverterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [tool, setTool] = useState<ToolKey>('pandoc');
const [fromFormat, setFromFormat] = useState('');
const [toFormat, setToFormat] = useState('');
const [filePath, setFilePath] = useState('');
const [batchMode, setBatchMode] = useState(false);
const [inputFolder, setInputFolder] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [converting, setConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputFormats = converterFormats[tool].input;
const outputFormats = converterFormats[tool].output;
useEffect(() => {
setFromFormat('');
setToFormat('');
}, [tool]);
const handleBrowseFile = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFile?.();
if (typeof result === 'string') {
setFilePath(result);
}
}, []);
const handleBrowseInputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setInputFolder(result);
}
}, []);
const handleBrowseOutputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setOutputFolder(result);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const handlers: Array<() => void> = [];
const statusHandler = (_event: unknown, data: { percent?: number; message?: string }) => {
if (typeof data?.percent === 'number') setProgress(data.percent);
};
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
setConverting(false);
setProgress(100);
if (data?.error) {
setError(data.error);
toast.error(`Conversion failed: ${data.error}`);
} else {
toast.success('Conversion complete');
closeModal();
}
};
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
setProgress(Math.round((data.current / data.total) * 100));
}
};
const unsubStatus = window.electronAPI?.on?.('conversion-status', statusHandler) ?? (() => {});
const unsubComplete =
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
const unsubBatch =
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
handlers.push(unsubStatus, unsubComplete, unsubBatch);
return () => handlers.forEach((h) => h());
}, [closeModal]);
const handleConvert = async () => {
if (!fromFormat || !toFormat) {
setError('Select both source and target formats');
return;
}
if (!batchMode && !filePath) {
setError('Select a file to convert');
return;
}
if (batchMode && (!inputFolder || !outputFolder)) {
setError('Select both input and output folders');
return;
}
setConverting(true);
setProgress(0);
setError(null);
try {
if (batchMode) {
await window.electronAPI?.converter?.convertBatch?.({
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
} else {
await window.electronAPI?.converter?.convert?.({
tool,
inputPath: filePath,
fromFormat,
toFormat,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
toast.error(`Conversion failed: ${msg}`);
setConverting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Universal Converter</DialogTitle>
<DialogDescription>
Convert between image, audio, video, document, and markup formats
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{!batchMode && (
<div className="flex items-center gap-2">
<Input
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="Select a file..."
className="flex-1"
aria-label="File path"
/>
<Button variant="outline" onClick={handleBrowseFile}>
Browse
</Button>
</div>
)}
<div>
<Label htmlFor="converter-tool">Tool</Label>
<Select value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
<SelectTrigger id="converter-tool" aria-label="Conversion tool">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(converterFormats) as ToolKey[]).map((key) => (
<SelectItem key={key} value={key}>
{toolLabels[key]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="converter-from">From</Label>
<Select value={fromFormat} onValueChange={setFromFormat}>
<SelectTrigger id="converter-from" aria-label="Source format">
<SelectValue placeholder="Source format" />
</SelectTrigger>
<SelectContent>
{inputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="converter-to">To</Label>
<Select value={toFormat} onValueChange={setToFormat}>
<SelectTrigger id="converter-to" aria-label="Target format">
<SelectValue placeholder="Target format" />
</SelectTrigger>
<SelectContent>
{outputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Switch checked={batchMode} onCheckedChange={setBatchMode} id="batch-mode" />
<Label htmlFor="batch-mode">Batch mode</Label>
</div>
{batchMode && (
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<Input
value={inputFolder}
onChange={(e) => setInputFolder(e.target.value)}
placeholder="Input folder"
className="flex-1"
aria-label="Input folder"
/>
<Button variant="outline" size="sm" onClick={handleBrowseInputFolder}>
...
</Button>
</div>
<div className="flex items-center gap-2">
<Input
value={outputFolder}
onChange={(e) => setOutputFolder(e.target.value)}
placeholder="Output folder"
className="flex-1"
aria-label="Output folder"
/>
<Button variant="outline" size="sm" onClick={handleBrowseOutputFolder}>
...
</Button>
</div>
</div>
)}
{converting && (
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.max(2, progress)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
</div>
)}
{error && (
<div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error}
</div>
)}
</div>
<ExportDialogFooter
onCancel={closeModal}
onSubmit={handleConvert}
submitting={converting}
submitLabel="Convert"
/>
</DialogContent>
</Dialog>
);
}

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