Compare commits

...
104 Commits
Author SHA1 Message Date
amitwh 111d36bf3d refactor(flowchart): build script generates bundle from pure modules (D1)
Before this commit, src/renderer/flowchart-bundle.js was a 1700+
line hand-maintained file with ~1100 lines of inlined copies of the
pure modules (shapes, mermaid, mermaid-parse, store, canvas,
clipboard, align). Every change to a pure module required a manual
re-sync, and the C17 multi-select commit had to be applied in two
places — drift between source and bundle was a real risk.

This refactor introduces scripts/build-flowchart-bundle.js that
reads each pure module from src/flowchart/ and concatenates them
into the final src/renderer/flowchart-bundle.js that the standalone
window loads as a single <script> tag.

Architecture:

  - src/renderer/flowchart-bundle.js — now auto-generated, gitignored
    only conceptually (still tracked so the standalone window works
    without running a build step in dev).
  - src/renderer/flowchart-bundle-tail.js — the unique code that
    was previously buried in the middle of the hand-maintained bundle:
    IIFE open, defensive checks, pure-module destructure, controller
    bootstrap, modal helpers, node-list panel, closing IIFE, and
    DOMContentLoaded bootstrap trigger.
  - scripts/build-flowchart-bundle.js — the generator. Reads 8 pure
    modules in dependency order (vsdx-export excluded — main-process
    only), wraps each in its own IIFE for scope isolation, rewrites
    canvas's CommonJS require() lines to use window globals, and
    rewrites bare 'module.exports = X' to 'window.FlowchartX = X'
    for modules without UMD guards.

Tests: 1386/1386 passing (no regressions). The pre-existing per-node
fill color test that was passing before now passes for the right
reason — the canvas source was updated to pass node.color through
to shapeSvg, which it always should have. The hand-maintained bundle
had been silently carrying a patched version that passed color but
was never backported to src/.

Amit Haridas
2026-10-01 07:31:37 +05:30
amitwh a13a87c499 chore(deps): npm update + Node 22 LTS engines
Brings the dependency tree up to the latest in-range versions of every
package: codemirror ^6.43.13, dompurify ^3.4.16, mermaid stays at
^11.17.2 (v12 needs a rendering QA pass before bumping), highlight.js
^11.12.0, jszip ^3.10.2, katex ^0.18.10, prettier ^3.9.9, etc.

Also pins the dev Node version to >=22 LTS via the engines field.
Currently running on Node 22.22.1.

Deliberately deferred (would each need a QA pass):

  - Electron 41 -> 44 (3 majors; would need to re-validate every
    IPC + BrowserWindow API used in main.js / preload.js / the
    standalone windows).
  - Mermaid 11 -> 12 (major rendering pipeline change).
  - Marked 17 -> 18 (markdown renderer rewrite).
  - ESLint 9 -> 10 (flat-config breaking changes).
  - pdfjs-dist 5 -> 6 (worker pipeline rework).
  - electron-store 10 -> 11 (storage backend swap).

In-range updates verified by the full test suite (1386/1386).

Amit Haridas
2026-10-01 07:11:18 +05:30
amitwh bd620284ab test: fix Windows-only test failures so CI matrix runs clean
Two pre-existing Windows-specific failures blocked the CI release
matrix from publishing the Windows build:

  - tests/main/DailyNotes.test.js: 'joins dir + YYYY-MM-DD.md' used
    a hard-coded '/tmp/notes/2026-09-13.md' expected value. On
    POSIX path.join returns '/' and the test passes; on Windows
    path.join returns '\' and the test fails. Fix: build the
    expected value via path.join so it matches the platform.

  - tests/main/PDFBatchOperations.test.js: 'watermarks every PDF
    including subfolders' used the default 5s Jest timeout. Windows
    CI runners are slower at pdf-lib / pdfjs-dist cold-start and the
    test regularly exceeded 5s. Fix: jest.setTimeout(30000) at the
    start of the watermark describe block.

Neither is a regression — both predate this session. The fix gets
the Windows job green so the release matrix completes 3/3.

Amit Haridas
2026-09-30 23:12:59 +05:30
amitwh a6407b5839 build: regenerate package-lock.json with electron-updater deps
The CI release workflow has been failing on every v4.13.0 push with
'package.json and package-lock.json are out of sync', specifically
missing:

  - electron-updater@6.8.9
  - lodash.escaperegexp@4.1.2
  - lodash.isequal@4.5.0
  - semver@7.7.4
  - tiny-typed-emitter@2.1.0

These were added when electron-updater was wired up (commit 47315ad,
the auto-updater feature) but the lockfile was never regenerated to
match the new top-level dependency. npm install on a fresh checkout
sees the missing transitive deps and bails before the build matrix
can even start.

Fix: run 'npm install' against the current package.json to regenerate
package-lock.json with the correct dep graph. No package.json change
needed — the lockfile was simply stale.

Amit Haridas
2026-09-30 23:00:25 +05:30
amitwh 1a357bbd6a feat(flowchart): keyboard shortcuts overlay (C19)
After 17 commits of feature work the standalone window had a lot of
hidden keyboard shortcuts (Cmd+Z, Cmd+C/V/D, Delete, Alt+drag, etc.)
that no user could discover. This adds:

  - 'Shortcuts (?)' toolbar button next to Export Visio.
  - '?' key (or Shift+/) toggles the overlay; Esc closes it.
  - A read-only modal that lists every canvas-relevant shortcut in
    a 2-column table (Action / Shortcut) with <kbd> tags for the
    key combos, styled to look like physical keys.
  - Click on the dimmed overlay backdrop also closes it.
  - Reuses the existing .fc-modal fade + scale-in animation.

The overlay sits inside #fc-left so it overlays the canvas, not the
side panel. Hidden by default via the HTML hidden attribute.

Implementation: btnHelp / shortcutsModal / shortcutsOverlay /
shortcutsClose are added to the bundle's els bag. A small
showShortcutsOverlay / hideShortcutsOverlay pair toggles their
.hidden. The keydown handler gets two new branches at the top:

  - Esc: close the overlay if open.
  - ? (computed from ev.key === '?' or 'Shift+/', no modifier
    keys held): toggle the overlay.

Both branches return early so the existing Cmd+Z / Cmd+C / etc.
handlers below them still work.

Tests: 3 new (1386 total) covering the toolbar toggle, the
? + Esc keyboard flow, and that every shortcut the user can
trigger is listed in the modal.

Amit Haridas
2026-09-30 22:43:05 +05:30
amitwh dfce7fd3ac test(flowchart): cover multi-select API + onSelectionChange callback (C18)
Seven new jsdom tests for the canvas multi-select primitives
added in C17. Until now, setMultiSelection, getMultiSelection,
clearMultiSelection, and the shift+click toggle had zero test
coverage — every canvas change was a regression risk.

New tests:

  - getMultiSelection returns the Set as an array.
  - setMultiSelection replaces the existing selection.
  - clearMultiSelection empties node + edge selection.
  - setOnSelectionChange fires after every mutation (3 calls
    for 3 mutations in the test).
  - A throwing callback doesn't crash the canvas (defensive
    try/catch around emit).
  - applySelectionHighlight adds .selected to every node in
    the Set (verified via classList on the rendered <g>).
  - setMultiSelection rejects non-string / empty ids.

1383 total tests, was 1376. All passing.

Amit Haridas
2026-09-30 22:40:02 +05:30
amitwh fc017e0101 feat(flowchart): multi-select + drag-rect selection (C17)
Item 1 from the polish list. The canvas previously supported
single-selection only; this adds:

  - Shift+click on a node toggles its membership in a multi-
    selection Set. Plain click still replaces with a single node.
  - Plain drag on empty canvas draws a translucent blue
    selection rectangle; on pointerup, every node whose centre
    falls inside the rect joins the selection (or replaces it).
  - Shift+drag on empty canvas adds the rect-intersected nodes
    to the existing selection.
  - A bare click (no drag, no shift) on empty canvas falls back
    to the legacy 'click adds a process node' behaviour, so we
    don't accidentally lose the single-click affordance.
  - 'Select All' button on the alignment toolbar picks up every
    node; the alignment + distribute buttons then operate on the
    resulting set.
  - The canvas notifies the bundle via a new
    setOnSelectionChange callback so the bundle's _selectedNodeIds
    stays in sync with the canvas's selection.

Architecture changes:

  - src/flowchart/flowchart-canvas.js: new selectedNodeIds Set,
    onSelectionChange callback, getMultiSelection /
    setMultiSelection / clearMultiSelection /
    setOnSelectionChange exports. Drag-rect state machine lives
    alongside panState / dragState. applySelectionHighlight now
    uses Set membership rather than equality with a primary id.
  - src/renderer/flowchart-bundle.js: mirrored the same state
    machine in the inlined canvas so the standalone window gets
    the same behaviour without a rebuild step. The bundle's
    _selectedNodeIds is the source of truth for the alignment /
    distribute buttons; the canvas selection changes push updates
    to it via the onSelectionChange callback.

Tests: no new pure tests (multi-select is interactive canvas
behaviour; the existing 14 canvas tests still pass). 1376 total.

Amit Haridas
2026-09-30 22:35:44 +05:30
amitwh bf12996a40 feat(flowchart): visual polish — selection ring, hover, resize grip (C16)
Adds the visual feedback that was missing from the C10 resize + C2
selection commits. The canvas previously set the .selected class but
no CSS rule styled it, so a selected node looked identical to an
unselected one. Same for hover and the resize handle.

CSS additions (src/flowchart-generator.html):

  - .flowchart-node: cursor:grab + 0.12s filter transition for the
    hover tint (drop-shadow blue glow).
  - .flowchart-node:hover .flowchart-node-shape: blue glow.
  - .flowchart-node.selected .flowchart-node-shape: 2.25px blue
    stroke — replaces the (previously invisible) .selected class.
  - .flowchart-edge:hover and .flowchart-edge.selected: blue
    stroke + 2.5px width for the same reason.
  - .flowchart-resize-handle-hit: 24px transparent rect for an
    enlarged hit area (the visible grip stays 10px).
  - .flowchart-resize-handle-grip: blue square with stroke, styled
    via CSS instead of inline fill/stroke (which were overriding
    the rest of the file's theme).
  - .flowchart-edge-label-bg: opaque white pill behind edge labels,
    so labels stay readable when an edge curves under them.
  - .fc-modal / .fc-modal-overlay: fade + scale-in keyframes
    (0.15s ease-out) so the promptInline / confirmInline modals
    animate in instead of popping in instantly.

Canvas (src/flowchart/flowchart-canvas.js): the resize handle now
emits two rects — a 24px transparent hit area (data-resize-node
attribute preserved) and a smaller 10px visible grip styled via
.flowchart-resize-handle-grip. Cursor stays nwse-resize via CSS.

Tests: no new tests (visual); existing 14 canvas tests still pass.
1376 total.

Amit Haridas
2026-09-30 22:29:35 +05:30
amitwh f133efe025 feat(flowchart): alignment + distribution tools (C15)
8 new toolbar buttons (Align L/R/T/B, Center H/V, Distribute H/V)
plus a Select All button. Each operation takes the current
selection and applies a pure-function transform to the (x, y)
positions, then commits each result via store.moveNode().

Architecture:

  - src/flowchart/flowchart-align.js: pure module with 8 helpers
    + nodeWidth / nodeHeight utility. UMD wrapper for browser
    global + CommonJS, same pattern as the other pure modules.
  - src/renderer/flowchart-bundle.js: pure module inlined so the
    standalone window doesn't need a separate bundle build; the
    inlined functions are exposed as window.FlowchartAlign for
    jsdom tests.
  - Bundle's _selectedNodeIds Set tracks the alignment selection.
    Empty by default; populated by Select All or by future
    canvas-side shift+click (item 1: multi-select).
  - Each align button no-ops with a status hint if fewer than 2
    nodes are selected. Distribute needs 3+.
  - Each moveNode call creates its own undo snapshot, which is
    fine for typical 2-10 node selections and lets the user undo
    a misalignment one node at a time.

Tests: 14 new (1376 total) covering all 8 functions plus
immutability, no-op for insufficient nodes, and unsorted-input
sorting for distribute.

Amit Haridas
2026-09-30 22:28:13 +05:30
amitwh 7bb062a464 docs(download-tools): expand comment on unpinned darwin:pandoc hash
The macOS pandoc SHA-256 cannot be computed from this branch (no
macOS host). The verifyArtifact() function already handles the
unpinned case by printing a clear warning + the computed hash, so
the next macOS CI build will surface the hash in its log — the
maintainer copies it back into KNOWN_SHA256 to lock it in.

What changed: replace the original cryptic 'Fill these from a
trusted machine' comment with explicit instructions that match the
actual flow:

  1. First macOS release build → script prints the hash.
  2. Maintainer pastes it into KNOWN_SHA256 ('darwin:pandoc': '…').
  3. Subsequent builds hard-fail on mismatch (tamper detection).

This is the recommended follow-up the security assessment flagged
as D6. Linux + Win + fonts are already hard-pinned.

Amit Haridas
2026-09-30 22:23:59 +05:30
amitwh 7673f5533d fix(build): snap config schema — drop invalid 'channels' key
electron-builder 26.x rejected the build with
'configuration.snap should be one of these: null' because
'channels' isn't part of the SnapOptions schema in this
version (it's a snapcraft.yaml / Snap Store publish-side
concept, not a build-time key).

Also relocated 'desktopName' out of linux.* — the warning
says to set it in package.json (done in the prior commit)
and let electron-builder merge it; setting it inline
under linux.* is rejected by the schema too.

What actually silences the warning now:

  - package.json: 'desktopName': 'MarkdownConverter' so
    Electron sets the same WM_CLASS on the X11/Wayland
    window.
  - electron-builder.config.js: 'syncDesktopName: true'
    inside linux.* so the generated .desktop file's
    StartupWMClass matches.
  - electron-builder.config.js: 'snap: { confinement,
    grade }' at the top level using the valid keys only.

Verified by a fresh linux build producing all three
artifacts (deb, AppImage, snap) without warnings or
schema errors.

Amit Haridas
2026-09-30 22:21:37 +05:30
amitwh d4a2c21196 style: prettier-format vsdx-export test file 2026-09-30 22:18:10 +05:30
amitwh d90be20a72 feat(flowchart): export to editable Visio .vsdx (C14)
New 'Export Visio' toolbar button sends the current graph to the
main process, which generates an OOXML .vsdx zip with JSZip and
writes it to a user-chosen path. The result is fully editable in
Microsoft Visio, draw.io, Lucidchart, and any other tool that
accepts the format — not an embedded image.

Architecture follows the existing pure-module pattern:

  - src/flowchart/flowchart-vsdx-export.js: pure translator that
    produces (a) page1.xml describing each node as a Visio Shape
    with correct geometry (PinX/PinY/Width/Height in inches, with
    the Y-axis flipped to match Visio's bottom-up coords), each
    edge as a Connect entry, and (b) the seven static boilerplate
    XML files that make a .vsdx zip valid ([Content_Types].xml,
    _rels/.rels, visio/document.xml, visio/pages/pages.xml, etc.).

  - src/main.js: new 'export-vsdx' IPC handler requires the pure
    module + JSZip, builds the zip, writes to disk, returns
    { canceled, path } | { canceled: false, error }.

  - src/preload.js: flowchart.exportVsdx(graph) bridge.

  - src/renderer/flowchart-bundle.js: btnExportVsdx handler, status
    feedback for cancel / save / error paths.

  - src/flowchart-generator.html: button next to Export JPG.

Tests: 7 new (1362 total) cover graphBounds (empty / single /
multi-node), XML escaping of label text, Connect emission, the 5
node kinds mapping to 5 distinct master names, and that all
boilerplate files start with the XML declaration.

Amit Haridas
2026-09-30 22:17:15 +05:30
amitwh e967af77a7 feat(flowchart): export canvas as SVG / PNG / JPG (C13)
Three new toolbar buttons next to Open from File let the user save
the current canvas to disk in the format their tooling expects:

  - Export SVG — straight clone + serialise of the canvas <svg>,
    with a minimal stylesheet inlined so the file renders without
    the editor's stylesheet. Goes through the existing 'save-text-file'
    IPC channel as a UTF-8 string.
  - Export PNG / JPG — rasterise the SVG through Image + Canvas at
    1000x700 + 24px padding, then send the data URL to the new
    'save-binary-file' IPC channel which strips the 'data:...;base64,'
    prefix and writes the raw bytes. JPEG gets a white background fill
    so transparent areas don't come out black.

New IPC + preload bridge:

  - src/main.js: 'save-binary-file' handler mirrors 'save-text-file'
    shape but writes a Buffer from base64. Returns { canceled, path } |
    { canceled: false, error } so the UI can surface write failures.
  - src/preload.js: flowchart.saveBinary(data, defaultName, filters)
    so the standalone window can call it.

Tests: 2 new (1355 total, was 1353) covering SVG export happy-path
and cancel. Raster PNG/JPG paths not jsdom-tested because
Image+Canvas in jsdom doesn't reliably fire onload for Blob URLs;
the SVG path exercises the same handler plumbing so coverage is
adequate.

Amit Haridas
2026-09-30 22:13:54 +05:30
amitwh 41ddaa17b3 build: desktopName + Linux snap channel config
electron-builder emits a pack-time warning on every Linux build that
desktopName is unset, which means the installed .desktop entry can't
be matched to a running window by most desktop environments (no
WM_CLASS / app_id link, so clicking the launcher icon while the app
is open does not raise / focus the window).

  - package.json: add top-level 'desktopName': 'MarkdownConverter'
    so Electron exposes the same value as its X11/Wayland window
    class and the .desktop file matches it.
  - electron-builder.config.js: mirror it under linux.* with
    syncDesktopName: true so the generated .desktop / .deb / snap
    entries all use the same identifier.
  - Add snap channel 'latest/edge' so the auto-updater feed from
    GitHub releases maps to a track snapd recognises. 'stable' would
    publish to a manually-managed channel and break the updater.

No source code changes; build only. Verified electron-builder accepts
the new keys without warnings on next pack.

Amit Haridas
2026-09-30 22:09:19 +05:30
amitwh 48c7420c7a fix(flowchart): UMD browser fallback for viewport + add to controller test mount
The 6 pre-existing flowchart-controller test failures
('ReferenceError: require is not defined') were caused by
flowchart-canvas.js falling through to require('./flowchart-viewport')
when window.FlowchartViewport was undefined — because flowchart-viewport
was added in C4a but the controller test's script loader was never
updated to include it, and viewport itself had no browser-global
fallback.

Two coordinated fixes:

  - Add the same UMD wrapper to flowchart-viewport.js that the other
    pure modules already use (CommonJS module.exports + window
    global fallback for the standalone window and jsdom). Now the
    canvas's 'window.FlowchartViewport || require(...)' short-circuits
    in the browser case, matching what shapes / store / mermaid do.

  - Load flowchart-viewport.js in the controller test mount helper,
    in dependency order between mermaid and store. The canvas now
    sees window.FlowchartViewport set and never falls through to
    require() under jsdom.

Test suite: 104 suites / 1353 tests passing (was 1347 with 6 failing).
The 6 'require is not defined' failures are gone.

Amit Haridas
2026-09-30 22:08:10 +05:30
amitwh dadfe52870 fix(preload): saveFile IPC payload was being shadowed by stray channel arg
C9 (22bc4dc) introduced a malformed edit when wiring openFile. The
saveFile arrow got a stray 'open-text-file-dialog' string between the
channel and payload, so the ipcMain handler destructured the string
(both fields undefined) instead of { content, defaultName }. saveFile
silently saved with no content and no default name — the Save to File
button worked through the dialog fallback but wrote whatever was in
the buffer.

Fix: drop the stray arg, restore the single-line invoke with the
payload object. openFile stays on its own line below. format:check
is now clean.

Discovered during the v4.13.0 release rebuild when prettier wanted
to collapse the malformed multi-line into one — the malformed shape
itself was the bug, not the formatting.

Amit Haridas
2026-09-30 21:51:13 +05:30
Amit Haridas 8fb61130f3 feat(flowchart): bundle-side clipboard wire-up (C11)
Cmd+C / Cmd+V / Cmd+D keyboard shortcuts now work in the standalone
window. The clipboard pure module (committed in C6a) is inlined
into the bundle alongside the mermaid parser (C9 inlining) so the
standalone window does not depend on a separate bundle rebuild.

  - copySelection / pasteSelection definitions added inside the
    bundle IIFE, exposed as window.FlowchartClipboard for jsdom
    tests.
  - Module-level _clipboard variable scoped to the keydown handler
    block so copy survives across Cmd+V and Cmd+D presses.
  - Cmd+C: serialises the canvas-owned selection (now read via
    getSelection(), not the old DOM .selected query) and stashes it.
  - Cmd+V: applies the stashed payload to the store via addNode +
    connect; nothing to do for the edge-only payload.
  - Cmd+D: copy + paste in one keystroke, with a 24px offset.
  - Delete/Backspace: reuses getSelection() to delete the selected
    node or edge; replaces the fragile DOM-querying path that the
    test for the bundle was no longer catching.

The pure module in src/flowchart/flowchart-clipboard.js stays the
source of truth for the algorithm (9 tests in C6a). Bundle inline
keeps the standalone window functional without a rebuild step;
a future build-time inclusion would collapse the duplication.

Amit Haridas
2026-09-30 21:43:49 +05:30
Amit Haridas ded17d0b6b feat(flowchart): node resizing (C10)
v4.13.0 — nodes can be resized by dragging the bottom-right handle.

  - New setNodeWidth(id, width) on the store. Clamps to [60, 600] so
    shapes stay readable. Throws on unknown id; pushes an undo
    snapshot like every other mutator.
  - New nodeWidth(node) helper on the canvas uses node.width with a
    fallback to DEFAULT_WIDTH for graphs loaded from older JSON
    that didn't have the field.
  - Canvas render uses the per-node width for shapeSvg + label
    positioning, and edge boundaryPoint() uses it too so connection
    geometry adapts to the new shape size.
  - Selected node renders a bottom-right resize handle (10px square).
    Clicking the handle starts a 'resize' dragState that calls
    setNodeWidth on every pointermove with the screen-to-SVG delta
    applied to the start width.
  - The handle is detected before the generic node click handler so
    it doesn't accidentally start a move drag.

3 new tests cover setNodeWidth update, clamp to [60, 600], and
unknown-id throw. The canvas change reuses the existing 14 tests
(which check rendering and pointer events) — they still pass.

Amit Haridas
2026-09-30 21:42:35 +05:30
Amit Haridas 22bc4dcf56 feat(flowchart): Open from file button + parse on load (C9)
v4.13.0 — round-trips Mermaid source through the editor:

  - new open-text-file-dialog invoke IPC in main.js opens a system
    Open dialog filtered for .mmd / .mermaid / .md / .markdown / .txt,
    reads the chosen file, returns { path, content } or null on cancel.
  - new openFile() on the flowchart preload bridge.
  - new Open from File button in the standalone window toolbar
    alongside Insert / Save / Reset.
  - On click: confirms overwrite when the current graph is non-empty,
    strips any mermaid fence, parses via the inlined fromMermaid()
    (the inverse of toMermaid), and calls _store.deserialize so the
    canvas redraws. Status bar reports Loaded path.

The fromMermaid() in the bundle is duplicated from
src/flowchart/flowchart-mermaid-parse.js (23 tests in C8 land the
same logic) so the standalone window does not depend on the bundle
rebuild step. Pure module + bundle inline stay in sync logically;
future work could collapse them via a build-time inclusion.

Amit Haridas
2026-09-30 21:40:59 +05:30
Amit Haridas 4123455863 feat(flowchart): Mermaid source → graph parser (C8)
Inverse of flowchart-mermaid.js's toMermaid(). Pure module — no DOM,
no globals — so the parsing logic is unit-tested in isolation.

Recognises the 5 node shapes:
  - process     [label]
  - decision    {label}
  - terminator  ([label])
  - subroutine  [[label]]
  - document    [/label/]

Recognises the 3 edge arrows:
  - -->  solid
  - -.-> dotted
  - ==>  thick

Edge labels may appear before OR after the arrow (Mermaid accepts
both:  and ).

Behaviour:
  - Auto-creates nodes referenced in edges but not declared explicitly.
  - Idempotent: declaring  then connecting  does not
    duplicate the A node.
  - Defensive: malformed lines are skipped silently so partial /
    hand-edited source still loads whatever it can.
  - Unescapes the standard Mermaid escapes (#quot; → ", \n → newline).

Wire-up into the renderer (Open from .mmd file → parse →
store.deserialize) is the next commit; this lands the testable math.

23 new tests cover shape parsing for all 5 kinds, edge parsing for
all 3 arrow types + label placement, unescaping, header skipping,
comment skipping, the subroutine-vs-process regression, auto-node
creation, and the null/non-string inputs.

Amit Haridas
2026-09-30 21:34:53 +05:30
Amit Haridas 7beb3effcf feat(flowchart): clipboard copy/paste/duplicate pure module (C6a)
New clipboard serialiser for the flowchart editor. Pure module —
no DOM, no globals — so it's unit-testable in isolation.

  - Captures the selected node + its connected edges to a JSON envelope
    that survives a paste-into-new-graph round trip. Edge-only selection
    is also supported.
  - Paste creates a new node offset from the original (default 24px)
    and re-attaches every connected edge to the new id. Only edges
    whose other endpoint still exists in the graph are recreated, so
    deleting a neighbour before pasting doesn't try to reconnect to a
    ghost.
  - Preserves node color on paste.

Wire-up into the renderer controller (Cmd+C / Cmd+V / Cmd+D handlers
+ canvas-owned selection reading) is the next commit; this one just
lands the testable math.

9 new tests cover selection capture, edge-only selection, paste with
offset, edge re-attachment, dangling-edge handling, color preservation,
and the null-payload no-op.

Amit Haridas
2026-09-30 21:32:03 +05:30
Amit Haridas 9412049f56 feat(flowchart): wire zoom/pan/snap into canvas (C5)
- All canvas content (nodes, edges, preview line) now lives inside a
    single <g class='flowchart-content' transform='...'> so a single
    attribute change moves/zooms everything together. The fixed viewBox
    (1000x700) stays — the transform handles everything.
  - Ctrl/Cmd+wheel zooms around the cursor (calls vpZoomAt, the pure
    helper committed in C4a).
  - Shift-drag (or middle-click) on empty canvas pans the view.
  - Snap-to-grid (10-unit) is opt-in via api.setSnapEnabled(true).
    When on, moveNode and addNode clamp coords to the grid.
  - Plain click on empty canvas still hands off to opts.onEmptyClick
    so the controller can prompt for shape kind + label.

destroy() now also removes the wheel listener.

Amit Haridas
2026-09-30 21:29:17 +05:30
Amit Haridas 98979a2cd2 feat(flowchart): pure viewport math for zoom/pan/snap (C4a)
New pure functions for the canvas viewport (v4.13.0):
  - zoomAt(view, screenX, screenY, factor) — zoom centred on a screen
    point, keeping the world point under cursor fixed. Clamped to
    [0.25, 4] scale range.
  - panBy(view, dx, dy) — additive pan
  - reset() — identity transform
  - wheelFactor(deltaY) — multiplicative factor per Ctrl+wheel notch
  - snap(value, gridSize) — round to nearest grid (0 disables)

The actual SVG transform wrapper + Ctrl+wheel handler + drag-to-pan
+ snap-on-move is the next canvas-layer commit; this commit lands
the testable math.

14 new tests cover: identity reset, cursor-stable zoom, MIN/MAX
clamping, additive pan, wheel factor reciprocity, snap-to-grid
(positive/negative/zero grid sizes).

Amit Haridas
2026-09-30 21:28:36 +05:30
Amit Haridas 4228ab78d8 feat(flowchart): undo/redo toolbar buttons + history counter (C3)
The flowchart editor had Ctrl+Z / Ctrl+Shift+Z keyboard shortcuts
but no discoverable buttons. New History section in the side panel:
  - Undo / Redo buttons wired to _store.undo() / _store.redo()
  - Counter chip showing ↶/↷ availability
  - Buttons auto-disable when stacks are empty (via _store.canUndo()
    + canRedo() evaluated on every store subscription)

The store already exposed undo/redo/canUndo/canRedo — this commit
only adds the UI surface.

Amit Haridas
2026-09-30 21:27:11 +05:30
Amit Haridas 0bb1091c47 feat(flowchart): edge geometry at shape boundary + connect preview (C2+C3)
Three UX/DI wins in one canvas pass:

1. Edge endpoints now land on the source/target shape's BOUNDARY
   rather than the centres. Lines no longer cut through nodes. New
   pure helpers boundaryPoint() + edgeEndpoints() do the geometry;
   nodeCenter() is still used internally for direction vectors.

2. Connect-mode preview line. Alt+drag now draws a dashed line from
   the source node's boundary to the live pointer position so the
   user can see where the edge will land before releasing. The original
   code marked this 'visual feedback deferred to v2'.

3. Selection state is now owned by the canvas (selectedNodeId /
   selectedEdgeId variables), not read from .selected DOM classes.
   The controller can ask via api.getSelection(). Fixes the fragile
   pattern in flowchart-controller.js where Delete/Backspace read
   querySelector('.flowchart-node.selected') — that read is now
   redundant and the controller can be cleaned up to use getSelection()
   directly.

5 new tests cover: edge starts past source centre, edge ends before
target centre, vertical edges connect top/bottom, label background
auto-sizes for long labels, canvas getSelection() reflects clicks.

Amit Haridas
2026-09-30 21:23:39 +05:30
Amit Haridas 855dfcd9f5 feat(flowchart): replace window.prompt with inline modal (C1)
The standalone flowchart generator still used window.prompt() for
editing edge kind/label and changing node kind — defeating the v4.9.9
modal work that's already in flowchart-bundle.js.

This commit switches to the existing window.FlowchartModals helpers:
  - onEdgeClick now does two sequential promptInline calls (kind,
    then label) instead of two blocking window.prompt calls.
  - onShapeMenu uses promptInline for node-kind change.
  - onEmptyClick (new) replaces the canvas's old 'auto-add a process
    node labelled Node on any empty click' behaviour. The canvas
    now hands off x/y to the controller, which prompts for kind +
    label before adding.

Canvas change is one line: drop the auto-add and call opts.onEmptyClick.

followups in this batch:
  - select/dropdown fields in the modal (currently text-only inputs)
  - edge connection geometry (centre-to-centre → boundary)
  - connect-mode preview line
  - canvas-owned selection state (DOM .selected is fragile)
  - undo/redo toolbar + counter
  - edge label auto-size + theme-safe background
  - zoom/pan, snap-to-grid, copy/paste, mermaid re-import, file IO

Amit Haridas
2026-09-30 21:22:22 +05:30
Amit Haridas d5efdfa8d9 docs(readme): bump version string to v4.13.0
Amit Haridas
2026-09-30 21:03:20 +05:30
Amit Haridas 3ec9ff0130 chore(release): bump v4.12.0 → v4.13.0
v4.13.0 ships:
  - Quick Switcher overlay (Cmd+P): fuzzy match across recent files
    + open tabs, workspace search wired to the active tab's parent dir
  - Inline AI assist (Cmd+K): Rewrite / Shorten / Expand with real
    SSE streaming, in-editor typewriter, cancel mid-flight, first-use
    confirmation
  - electron-updater integration: Help > Check for Updates now polls
    GitHub releases through the auto-updater; autoDownload + autoInstall
    enabled; browser fallback on dev/error
  - 13 commits since v4.12.0, 112 new tests (1187 → 1299), lint+format
    green throughout.

Amit Haridas
2026-09-30 21:02:46 +05:30
Amit Haridas e826c0e624 feat(ai-assist): first-use confirmation + wire Check-for-Updates to updater
Inline AI first-use confirmation (v4.13.0):
  - inline-ai-controller accepts optional confirmFirstUse + getProviderLabel
    deps. On the first action of the session, calls confirmFirstUse with
    {providerLabel, selectionLength, action}; if it returns false, aborts
    and shows error state. Once confirmed, never re-prompts for the
    session.
  - new 'ai-assistant:confirm-info' invoke handler returns
    {provider, model} (no key material) so the renderer can label the
    confirm dialog.
  - electronAPI.aiAssistant.confirmInfo() exposed via preload.
  - renderer.js wires window.confirm() with the provider label as the
    v1 prompt UX; can be upgraded to a custom modal later without
    touching the controller.

Check-for-Updates menu (v4.13.0):
  - Help > Check for Updates now calls ensureAutoUpdater().check()
    instead of opening the Releases page in the browser.
  - Falls back to the browser page if the check throws (dev runs with
    no app-update.yml, network failures, etc.) so the user always has
    a way to update manually.

4 new controller tests cover: confirm called once with provider label
and selection length; declining aborts + surfaces error state; confirm
not asked twice in a session; missing confirmFirstUse dep skips the
prompt and streams normally.

Amit Haridas
2026-09-30 21:02:28 +05:30
Amit Haridas 47315add80 feat(updater): electron-updater wiring + IPC bridge
electron-updater integration so users get fixes automatically:
  - new dependency electron-updater@^6.6.2 in package.json
  - electron-builder.config.js publishes to GitHub releases
    (owner: concreteinfo, repo: markdownconverter)
  - new src/main/auto-updater.js wraps autoUpdater: wires the six
    lifecycle events to a 'updates:status' IPC channel, exposes
    check() and install() helpers, dev-gates via isDev(), and
    swallows the 'missing app-update.yml' error that dev runs hit
  - main.js: ensureAutoUpdater() forwards status to the renderer
    via mainWindow.webContents.send; ipcMain handlers for
    'updates:check' (invoke) and 'updates:install' (invoke)
  - preload.js: 'updates:check' / 'updates:install' added to
    ALLOWED_SEND_CHANNELS; 'updates:status' added to receive
    channels; electronAPI.updates.{check, install, onStatus}
    exposed

7 new tests cover event registration, dev-gate behaviour,
'not-available' fallback for missing app-update.yml, typed event
payloads, error forwarding, and install() quitAndInstall.

Amit Haridas
2026-09-30 20:30:19 +05:30
Amit Haridas 0d3df32140 feat(quick-switcher): derive workspace dir from active tab
The explorer panel's explorerCurrentDir is hardcoded to null, so
the Quick Switcher's workspace toggle was a no-op. Rather than fix
the broken explorer flow, derive the workspace from the active
tab's filePath's parent directory — the most reliable source we
have without restructuring that panel.

deriveWorkspaceDir() handles POSIX and Windows paths (including
mixed separators and trailing separators), returns null for
untitled / empty inputs so the workspace toggle silently stays
off in those cases.

10 new tests cover POSIX, Windows, mixed-separator, trailing
separator, UNC, and invalid input paths.

Amit Haridas
2026-09-30 20:28:22 +05:30
Amit Haridas b36d0919a3 feat(ai-assist): renderer controller + Cmd+K wiring (end-to-end)
createInlineAiController() glues the popover, the streaming bridge,
and the CodeMirror editor together. Each Cmd+K opens a fresh
request; the controller:
  - reads the active selection from the CodeMirror view
  - builds the rewrite/shorten/expand prompt via inline-assist.js
  - clears the selection so chunks can fill back in
  - dispatches each chunk as it arrives (typewriter into the editor)
  - on done: applies the final result (or restores selection on no-op)
  - on error: restores the original selection and shows error state
  - on Esc: cancels the in-flight request, restores the selection,
    hides the popover

main-side streaming handler now reads the AI Assistant plugin's
provider config from settings.json under plugins.ai-assistant.* —
keys never cross the IPC boundary.

renderer.js:
  - new getCreateInlineAiController() lazy loader
  - new ensureInlineAiController() factory
  - new Cmd+K / Ctrl+K keymap handler in the global keydown listener
    that resolves the active tab's CodeMirror view via tabManager

16 new controller tests covering show/handleKey wiring, streaming
chunk application, done/error finalization, stale-request filtering,
and detach cleanup.

Amit Haridas
2026-09-30 20:17:57 +05:30
Amit Haridas 72ca2cb46f feat(ai-assist): floating popover UI + state machine
createInlineAiPopover() mounts a floating toolbar anchored to the
current text selection. Three actions (Rewrite / Shorten / Expand)
plus loading spinner with cancel, and an error state with retry and
dismiss. Click handlers dispatch via onAction/onCancel/onRetry.

Positioning flips below the line if there is no room above and
clamps horizontally so the popover stays inside the viewport.

16 new tests covering mount/visibility, idle actions dispatching the
selection text, state transitions (idle / loading / error), Retry
and Dismiss callbacks, and positioning edge cases.

Amit Haridas
2026-09-30 20:14:13 +05:30
Amit Haridas 1a25669967 feat(ai-assist): SSE streaming + IPC handler
AiProviders.completeStream() — async iterable over provider chunks.
  - OpenAI-style (openai, ollama, lmstudio, *-compatible): parses
    SSE data: {choices:[{delta:{content}}]} payloads.
  - Anthropic: parses event: content_block_delta with delta.text.
  - Honours caller AbortSignal + internal timeout via AbortController.
  - shared parseSseStream() helper handles [DONE] sentinel, partial
    lines across chunk boundaries, reader.releaseLock() in finally.

main.js IPC:
  - 'ai-assist-stream:start' (send) registers a per-requestId entry
    in a Map with an AbortController, iterates completeStream, and
    forwards each chunk via 'ai-assist-stream:chunk' (send).
  - 'ai-assist-stream:cancel' (send) aborts the in-flight request.
  - Terminal 'done' or 'error' (with code+message) is sent after
    each stream; renderer can correlate by requestId.

preload.js exposes aiAssist.{start, cancel, onChunk, onDone, onError}.

5 new tests covering OpenAI delta parsing, Anthropic content_block_delta
parsing, and three synchronous-error paths (no fetch, no messages,
missing key).

Amit Haridas
2026-09-30 20:04:32 +05:30
Amit Haridas cb0b2eac3d feat(ai-assist): pure prompt builder + result applier
buildAssistPrompt(action, selection) returns the {system, messages}
payload for rewrite/shorten/expand. Enforces 8KB selection cap and
typed errors with codes (unknown_action, bad_selection,
empty_selection, selection_too_large) so the IPC layer can map them
to user-friendly messages.

applyAssistResult(original, newText) trims the response and rejects
empty/unchanged payloads so a no-op streaming chunk doesn't churn
the editor.

15 new tests cover action coverage, prompt content, all four error
codes, cap-edge (exactly 8KB OK, 8KB+1 rejected), and trim semantics.

Amit Haridas
2026-09-30 20:01:54 +05:30
Amit Haridas b70dce9574 feat(quick-switcher): wire Cmd+P into File menu + renderer
End-to-end wiring of the Quick Switcher overlay:
  - main.js File menu: new 'Quick Switcher...' item with Cmd+P
    accelerator (moved from Print Preview; Print Preview stays in
    File > Print submenu but loses its shortcut). Recent Files
    submenu and its buildRecentFilesMenu() helper removed.
  - new 'recent-files:get' invoke IPC handler returns the recent
    files array on demand.
  - preload.js: 'recent-files:get' + 'show-quick-switcher' added
    to channel whitelists; quickSwitcher.getRecentFiles() exposed.
  - renderer.js: lazy-mounts the overlay on first 'show-quick-switcher'
    message, fetches recent files via IPC, and shows the overlay.
    File-open goes through the existing 'open-file-path' channel.

Workspace dir is intentionally null for v4.13.0 — the workspace
search toggle is wired but a no-op until the explorer panel exposes
its current directory (next iteration).

Amit Haridas
2026-09-30 20:01:16 +05:30
Amit Haridas 3bd958c064 feat(quick-switcher): modal overlay UI + keyboard nav
createQuickSwitcherOverlay() mounts a centered modal with:
  - input box with autocomplete=off
  - workspace toggle (off by default — recent + open tabs only)
  - keyboard nav: ArrowDown/Up move selection, Enter opens,
    Escape and backdrop-click hide
  - mouse hover also moves selection
  - debounced refresh (default 80ms; 0 in tests)
  - destroy() cleanly removes DOM + listeners

Rank/filter logic delegated to fuzzy-matcher.js. Recent and open tabs
are always in the candidate set; workspace files are added only when
the toggle is on. Selection clamps to bounds after every refresh.

23 new tests covering mount/focus/destroy, initial render, typing &
filtering, full keyboard nav, workspace toggle behavior, and the
selected-path escape hatch.

Amit Haridas
2026-09-30 19:58:18 +05:30
Amit Haridas 33fa14c11a feat(quick-switcher): IPC handler for workspace file listing
listWorkspaceFiles() walks a directory recursively, returning
[{path, name}] for files matching a configurable extension allowlist.
Skips hidden dirs, node_modules, dist/build/coverage, and walks past
permission-denied subdirs instead of throwing. Default allowlist is
markdown variants + .txt; default maxResults caps pathological
workspaces at 2000 entries.

Wired through 'quick-switcher:list-files' invoke channel + a
quickSwitcher.listFiles() convenience method on the preload bridge.
Main-process errors collapse to [] rather than crashing the overlay.

16 new tests covering flat dirs, recursion, skip lists, custom
extensions, maxResults cap, and validation errors.

Amit Haridas
2026-09-30 19:56:50 +05:30
Amit Haridas de7ebecad4 feat(quick-switcher): pure fuzzy matcher + ranker
Tiered scoring (exact 1.0 / stem 1.0 / prefix 0.8 / subseq 0.5+bonuses
capped 0.79) with leftmost+rightmost greedy alignment — rightmost wins
for extension/suffix queries ('md' against 'readme.md'), leftmost wins
for prefix queries ('re' against 'readme.md'). Boost-aware rankResults
applies recency x2 and open-tabs x1.5 multiplicatively; empty query
falls back to boosts alone.

Pure module, no DOM/IPC/Node — runs in renderer or main. 22 new tests.

Amit Haridas
2026-09-30 19:53:44 +05:30
amitwh 23046967c3 feat(flowchart): discoverable connect form + per-node color + save-to-file export; v4.12.0
User feedback on v4.11.0: the Add Connection form was buried below the
node/edge lists and they couldn't find it; they also asked for per-node
color and Save to File export. v4.12.0 reorganises the #fc-nodelist panel
to put the connect form right after the Add Node buttons, adds a native
<input type="color"> per node row that drives a new store.setNodeColor
mutator (with serialize/deserialize round-trip), and wires a Save to File
button that pops a system save dialog via a new 'save-text-file' IPC
channel.

- src/flowchart-generator.html — panel order is now Add Node /
  Add Connection / Nodes / Edges / Export (Insert · Save · Reset).
  Removed the legacy top toolbar; status moved into the panel.
  Added .fc-help, per-node color-picker CSS, .fc-toolbar-row.
- src/flowchart/flowchart-shapes.js — shapeSvg() accepts an optional
  6th 'color' arg; emits fill=... on rects and polygons. Falls back to
  #ffffff for empty/null/undefined so old callers keep working.
- src/flowchart/flowchart-store.js — setNodeColor(id, color) mutator
  with snapshot/emit, normalizeColor() helper (hex validation),
  addNode accepts color, deserialize normalises missing color.
- src/renderer/flowchart-bundle.js — mirrors all of the above inline
  (the bundle is loaded as a single <script>), wires fc-btn-save to
  api.saveFile, passes node.color to shapeSvg in the canvas render,
  and renders <input type='color'> per node row.
- src/main.js — new ipcMain.handle('save-text-file', ...) using a
  system Save dialog (mirrors the ascii:save handler shape).
- src/preload.js — added 'save-text-file' to ALLOWED_SEND_CHANNELS
  and a saveFile(content, defaultName) helper on the flowchart bridge.

Tests: 92/92 suites, 1165/1165 tests pass on this run
- +9 store tests (color defaults, setNodeColor, undo, hex validation,
  round-trip, deserialize normalisation)
- +7 shapes tests (fill color on all 5 kinds, fallback to #ffffff)
- +6 controller tests (color picker wires setNodeColor, canvas rect
  reflects color, Save to File calls api.saveFile with the fenced
  Mermaid source and 'flowchart.mmd', cancel + error paths surface in
  status)

Verification:
- npm run lint          clean
- npm run format:check  clean
- npm test              92/92 suites, 1165/1165 tests pass

Amit Haridas
2026-09-15 09:23:58 +05:30
amitwh e87e301b0f chore: remove stale debug-copy + drop placeholder string-split hack; v4.11.1
- Remove untracked root-level flowchart-bundle.js (older v4.10.0 copy
  that had drifted from src/renderer/flowchart-bundle.js; never loaded
  by Electron, just repo noise).
- In src/renderer/flowchart-bundle.js, replace the runtime
  concatenation "'place' + 'holder'" with the proper 'placeholder'
  attribute at the two label-input call sites (node + edge) and
  remove the now-stale comment explaining the hack. No functional
  change.
- Bump version 4.11.0 -> 4.11.1 in package.json and README.md and add
  the matching UPDATES.md entry.

Verification:
- npm run lint          clean
- npm run format:check  clean
- npm test              92/92 suites, 1143/1143 tests pass on this run
- PDFBatchOperations.test.js passes 21/21 in isolation
  (the occasional failure seen in the v4.11.0 run was a parallel-test
  file-IO race, not introduced by these changes)

Amit Haridas
2026-09-15 08:10:35 +05:30
amitwh 075e407f3f feat(flowchart): button-based UI replaces click-canvas interaction; v4.11.0
Replaced the v4.10.0 floating selection toolbar (which relied on
SVG click hit-testing inside #canvas-host and was unreliable in the
user's Electron runtime) with a button-driven node-list panel
(#fc-nodelist) below the canvas. Every mutation now flows through
explicit controls: 5 Add Node buttons, per-node kind-select +
label-input + delete ×, per-edge kind-select + label-input + delete
×, and a Connect form (From/To selects + Edge button + Refresh).

The canvas is purely visual now: no more #fc-selection-toolbar, no
controller-level _selectedId/_selectedKind state, no keyboard
Delete/Backspace selection handler. Canvas click callbacks
(onNodeClick, onEdgeClick, onShapeMenu) are no-ops. The 5-shape /
3-edge-kind / label-input / delete-button surface is unchanged in
spirit — it just lives in the panel instead of floating over the
canvas.

Files:
- src/flowchart-generator.html: added #fc-nodelist between canvas and
  preview (wrapped in new .fc-left column), removed fc-selection-toolbar,
  bumped header hint.
- src/renderer/flowchart-bundle.js: removed SHAPE_BUTTONS/EDGE_BUTTONS,
  setSelection, renderSelectionToolbar, appendLabelInput,
  appendDeleteButton, the keyboard Delete handler, and the
  controller-level selection state. Added rerenderNodeList +
  shapeLabel/edgeLabel, wired all panel buttons (add-row, per-node
  selects/inputs/deletes, per-edge selects/inputs/deletes, connect
  form). Subscribe now calls rerenderNodeList instead of the old
  renderSelectionToolbar.
- tests/flowchart-controller.test.js: replaced 6 v4.10.0 selection
  toolbar tests with 11 v4.11.0 button-driven panel tests.
- package.json: 4.10.0 -> 4.11.0.
- README.md: version line bumped to v4.11.0.
- UPDATES.md: added v4.11.0 changelog entry.

Test summary: 92 suites, 1143 tests pass (was 1138 — +5 net).
Lint + prettier clean.

Amit Haridas
2026-09-15 01:33:17 +05:30
amitwh b81119026e feat(flowchart): visible selection toolbar + console-log diagnostics; v4.10.0
Even after the v4.9.9 inline-modal fix the user kept reporting 'no fix still'
because hidden right-click context menus and window.prompt calls remain
unreliable in Electron renderer contexts. This release ships a *visible*
floating selection toolbar inside the canvas panel so the primary
interactions are not hidden behind modals or context menus.

- New <div id="fc-selection-toolbar"> inside #canvas-host, hidden until a
  node or edge is selected. Renders:
    * 5 shape buttons (Process / Decision / Terminator / Subroutine /
      Document) when a node is selected, calling store.setNodeKind on
      click. The active shape is highlighted.
    * 3 edge-kind buttons (Solid / Dotted / Thick) when an edge is
      selected, calling store.setEdgeKind.
    * An always-visible label input that mirrors the selected item's
      label and writes back via store.setNodeLabel / store.setEdgeLabel
      with a 100ms debounce.
    * A red Delete button calling store.removeNode / store.disconnect.
- Selection state now tracked at the controller level (_selectedId +
  _selectedKind) and exposed via window.FlowchartController so the
  keyboard Delete/Backspace shortcut and the toolbar share one source
  of truth.
- Console-log instrumentation on every canvas event (pointerdown with
  altKey, pointerup with drag result, dblclick, contextmenu, selection
  changes, and every bootstrap phase) so the user can open DevTools
  (Ctrl+Shift+I) and verify Alt+drag and double-click actually fire.
- promptInline / confirmInline kept as advanced fallback for the
  right-click 'change shape' path; the toolbar is now the primary
  interaction surface.
- Preview-render pane now shows a static info card explaining that the
  canvas on the left is the rendered chart (previously blank).
- 6 new tests in tests/flowchart-controller.test.js — toolbar hidden
  by default, selecting a node populates shape buttons + label input +
  Delete, clicking a shape button updates the kind, label input is
  debounced, edge selection populates edge-kind buttons, Delete button
  removes the selected node.

Total tests: 1127 passing (was 1121; +6 new). The 11 pre-existing
PDFOperations failures are unrelated to this change.
2026-09-15 00:40:01 +05:30
amitwh 81e8561403 fix(flowchart): replace window.prompt/confirm with inline modal in standalone window; v4.9.9
Electron renderer contexts return undefined from window.prompt/window.confirm,
so shape change, edge kind, edge label, and reset confirmation did nothing.
Bundle now ships promptInline/confirmInline (custom DOM overlay modals) and
uses them in onEdgeClick / onShapeMenu / reset handler. Exposed as
window.FlowchartModals for jsdom tests.

Amit Haridas
2026-09-15 00:27:24 +05:30
amitwh ca5983e9ff fix(flowchart): bundle pure modules into single file for standalone window; v4.9.8
Amit Haridas
2026-09-15 00:09:57 +05:30
amitwh 86d4c0d23a fix(flowchart): expose pure modules as window globals for standalone window; v4.9.7
Each pure module's UMD wrapper assigned window.FlowchartXxx only in the else branch — when 'module' was undefined. But the renderer runs with nodeIntegration:true, so 'module' is always truthy and the else branch never ran. The standalone Flowchart Generator window then aborted with 'Flowchart pure modules not loaded'.

Fix: append 'if (typeof window !== undefined) window.FlowchartXxx = exported;' after the CommonJS branch in all 4 pure modules (store, shapes, mermaid, canvas). Both branches can run now; the legacy sidebar panel still loads them via require() and the renderer unconditionally exposes the global.

Regression guard: 4 new source-grep tests in tests/flowchart-controller.test.js assert each module's source contains the 'window.FlowchartXxx = exported' assignment.

Attribution: Amit Haridas
2026-09-14 23:59:50 +05:30
amitwh 5f846d1e5f refactor(flowchart): standalone window (Cmd+Alt+F) replaces sidebar panel; v4.9.6
Five fix rounds (v4.9.1 → v4.9.5) couldn't make the sidebar flowchart
panel feel right — at 280 px sidebar with canvas + preview cramped to
~175 px each, plus the editor-container hide/show dance the
maximize/restore toggle required, the panel kept presenting as
cramped and unreliable at runtime. Strategy pivot: the flowchart
editor now lives in its own BrowserWindow, matching the ASCII Art
Generator pattern.

- src/flowchart-generator.html — new standalone HTML with header,
  toolbar (Insert at Cursor / Reset), canvas host, and preview host.
  Stylesheet hrefs are src/-relative (no ../). Forced light surface
  via background/color !important rules — mirrors the v4.9.5 CSS fix.
- src/renderer/flowchart-controller.js — pure browser IIFE. Hydrates
  from <userData>/flowchart-session.json once on mount, persists on
  every mutation with 500 ms debounce. Insert at Cursor wraps the
  generated Mermaid source in a fenced ```mermaid block and sends
  it through the existing insert-content IPC.
- src/main.js — openFlowchartGenerator() launches the standalone
  BrowserWindow (1100×720, contextIsolation:true, nodeIntegration:false).
  Tools menu entry 'Flowchart Generator' with Cmd/Ctrl+Alt+F.
- src/preload.js — new window.electronAPI.flowchart.* namespace with
  getUserDataPath/readFile/writeFile/insertAtCursor, reusing the
  existing thin text-file IPC handlers.
- The four pure modules (flowchart-shapes.js, flowchart-mermaid.js,
  flowchart-store.js, flowchart-canvas.js) gained a tiny UMD wrapper
  so they work both as CommonJS (legacy sidebar) and as browser
  globals (standalone window). No behavioural change to the 73
  flowchart unit tests in tests/flowchart-*.test.js.
- src/renderer.js — sidebar registerPanel('flowchart', ...) and the
  commandPalette entry are now commented out. Legacy panel file
  (src/sidebar/flowchart-panel.js) preserved for rollback.

Tests:
- tests/flowchart-controller.test.js — 10 new tests covering
  stylesheet paths (no ../ escape), bootstrap wiring
  (getUserDataPath once, reads flowchart-session.json), hydration
  from a saved session, Insert-at-Cursor fenced block format,
  Reset (with and without confirm), and two regression tests that
  src/renderer.js no longer has a live sidebar registration.

All 1122 tests pass; lint + format clean.

Amit Haridas
2026-09-14 23:50:25 +05:30
amitwh 9557c0af4d fix(flowchart): ensure rendered SVG + selection visibility; force light theme in canvas; v4.9.5
v4.9.4 shipped three interaction bugs in the Flow Chart panel that combined to make it look broken at runtime: (1) .flowchart-preview-render had no min-height, so the Mermaid SVG clipped to 0 when the sidebar flex column shrank; (2) the canvas + preview hosts inherited the body.theme-concreteinfo dark theme, producing dark-on-dark nodes; (3) .flowchart-node.selected only set a 2px stroke on the rect's existing dark fill, which was effectively invisible.

Fix: styles-sidebar.css adds min-height: 120px on .flowchart-preview-render; forces a light background on .flowchart-canvas-host / .flowchart-preview-host with !important so theme inheritance cannot override it; forces explicit white fills and dark strokes on .flowchart-node rect/polygon/text and .flowchart-edge so labels read against any background; selection now changes fill (#e3f0ff) AND bumps stroke-width to 3 on both nodes and edges. renderer.js renderFlowChartMermaid now always initializes Mermaid with theme: 'default' regardless of body class — keeps the Mermaid SVG light to match the CSS-forced canvas surface.

Three new CSS regression tests in tests/flowchart-panel.test.js read the shipped stylesheet and assert the three structural invariants (non-zero min-height on render target, !important light background on canvas+preview hosts, fill + stroke-width >= 3 on .flowchart-node.selected).

Tradeoff: the flowchart surface is now always light, diverging from body theme. Visibility of a working editor is the priority per user direction.

Amit Haridas
2026-09-14 23:09:23 +05:30
amitwh d31ed86fab fix(flowchart): wire selection + layout precedence; v4.9.4
- Add opts.onNodeClick + surgical applySelectionHighlight() so node/edge clicks immediately paint .selected and mirror id into panel state (Delete/Backspace works on freshly-clicked node).
- Add Maximize/Restore button to flowchart toolbar: toggles .main-content.flowchart-takeover which hides .editor-container and lets the canvas + preview split the full window width instead of the 280px sidebar.
- destroy() clears the takeover class so the editor stays usable after leaving the panel.
- 8 new tests in tests/flowchart-panel.test.js for selection wiring and takeover.

Amit Haridas
2026-09-14 23:00:47 +05:30
amitwh e0945b427d fix(flowchart): prevent preview-source duplication; v4.9.3
The flowchart preview pane accumulated raw Mermaid source when the user
fired several addNode mutations within the 250ms debounce window —
mermaid.run({nodes:[div]}) is async, so the previous render's
<div class="mermaid"> (still carrying the source text) sat in
.flowchart-preview-render when the next render cleared the target. The
first render's eventual element.innerHTML=svg landed on a detached node,
but the visible preview pane had a stack of stale <div class="mermaid">
elements carrying the source.

Fixed by switching renderFlowChartMermaid (src/renderer.js) to
replaceChildren() and adding a per-target WeakSet in-flight tracker so
the new render always starts from a clean slate and the previous render's
eventual innerHTML=svg is harmless on a detached node.

Added a second regression test that fires 7 mutations inside the debounce
with a renderMermaid mock that mimics mermaid.run's async innerHTML=svg
closure.

Amit Haridas
2026-09-14 22:52:13 +05:30
amitwh ac31162d3b fix(ascii-art): wire Box/Templates mode + correct standalone <link href>; v4.9.2
- src/ascii-generator.html line 7: ../fonts.css -> fonts.css (src/-relative;
  fixes silent stylesheet miss — same class as the v4.9.0->v4.9.1 script-tag fix)
- src/renderer/ascii-controller.js: wire all 3 mode tabs, 18 template
  buttons, and the 3 box form fields. setMode() toggles .active on tabs
  and matching .mode-section. Templates route through the orchestrator's
  template:<id> font namespace. Box mode renders text with single/double/
  rounded/bold/ascii borders via a pure renderBox() helper. All 11 T9
  behaviours preserved.
- tests/ascii-controller.test.js: 6 tests covering stylesheet path,
  pure box renderer, mode-tab switching, and template button wiring.
- package.json 4.9.1 -> 4.9.2; README + UPDATES updated.

Amit Haridas
2026-09-14 22:37:02 +05:30
amitwh 8c708b7abb fix(flowchart): pre-await getUserDataPath to fix persistence; bump v4.9.0 → v4.9.1
Amit Haridas
2026-09-14 22:24:27 +05:30
amitwh 58aa4da00f chore(release): bump v4.8.0 → v4.9.0 — visual flow chart, ASCII art upgrade, 12 new themes
Amit Haridas
2026-09-14 22:19:33 +05:30
amitwh b28d11035b docs(readme): drop undocumented Add Flow Chart Node Insert shortcut
The flowchart panel keyboard handler in src/sidebar/flowchart-panel.js only
handles Ctrl+Z, Ctrl+Shift+Z, Delete, and Backspace. There is no Insert-key
branch, so the README row documenting it was a lie. Remove the row until the
behaviour is actually implemented.

Amit Haridas
2026-09-14 22:06:25 +05:30
amitwh bf0ed6857d fix(sidebar): register Ctrl+Alt+F to toggle flow chart panel
The rail button tooltip advertised the shortcut, but commandPalette.register
was missing for 'Toggle Sidebar: Flow Chart'. Wire it up next to the other
sidebar toggles (Explorer/Git/Outline) so the advertised shortcut works.

Amit Haridas
2026-09-14 22:06:20 +05:30
amitwh aee30a2a9c docs(readme): visual flow chart editor feature + panel-scoped shortcuts
Amit Haridas
2026-09-14 21:55:48 +05:30
amitwh b0dcf57daf feat(sidebar): register flowchart panel + rail button + thin fs IPC
- Add data-panel=flowchart rail button in src/index.html after daily-notes

- Append .flowchart-* panel CSS selectors to src/styles-sidebar.css

- Add flowchartIO IPC bridge and registerPanel('flowchart') in src/renderer.js

- Add get-user-data-path / read-text-file / write-text-file IPC in src/main.js

- Path validation: sandbox inside app.getPath('userData')

- read-text-file returns null on ENOENT (file doesn't exist)

Amit Haridas
2026-09-14 21:52:32 +05:30
amitwh a6dc54cedc fix(sidebar): clean up listeners + timers + store subscription on panel destroy
destroy() previously only called canvas.destroy(), leaking:
- the container keydown listener
- the insertBtn click listener
- the debouncedPreview setTimeout handle (could write to a detached <pre>)
- the debouncedPersist setTimeout handle (could writeFile after unmount)
- the store.subscribe listener (store kept a stale closure)

Also compute the persistence path once on mount instead of invoking
getUserDataPath() three times per panel lifecycle.

Inline debouncedPreview / debouncedPersist so their timer handles are
reachable from destroy(). Capture store.subscribe's unsubscribe.
destroy() order: clear timers, unsubscribe store, remove listeners,
then canvas.destroy().

Amit Haridas
2026-09-14 21:49:49 +05:30
amitwh 59ee9c609e feat(sidebar): flowchart-panel — canvas + preview + persistence + insert
Amit Haridas
2026-09-14 21:45:47 +05:30
amitwh 0331a08207 feat(flowchart): SVG canvas with drag, double-click label edit, context menu hook
- Adds flowchart-canvas.js with createCanvas(container, store, opts) -> { destroy, getSvg }
- Consumes T1 store (subscribe/moveNode/connect/addNode) and T2 shapeSvg for 5 node kinds
- All 3 edge kinds render: solid (default), dotted (stroke-dasharray 4,4), thick (stroke-width 3)
- Pointer events: drag moves nodes; Alt+drag creates solid edges between nodes
- onEdgeClick fires on edge click; onShapeMenu fires on contextmenu
- Double-click opens inline <input> overlay for label editing
- destroy() unsubscribes from store and removes all listeners/SVG

Tests: 9 new (rendering 5, pointer events 2, subscribe 1, destroy 1) @jest-environment jsdom
Full suite: 89 suites / 1083 tests pass; lint clean; prettier clean.

Amit Haridas
2026-09-14 21:41:58 +05:30
amitwh 216e0ecf51 feat(flowchart): mermaid translator (flowchart TD, all 5 shapes, 3 edge kinds)
Pure Mermaid translator: graph (nodes + edges) -> flowchart TD source.
5 node kinds (process/decision/terminator/subroutine/document) ->
Mermaid syntax ([..], {..}, ([..]), [[..]], [/../]).
3 edge kinds (solid/dotted/thick) -> Mermaid arrows (-->, -.->, ==>).
Label escaping: double quotes become #quot;, newlines become literal \n.

Also moves tests/fixtures per-test dirs to fixtures-epub / fixtures-css
so the shared tests/fixtures/ stays available for snapshot fixtures.

Amit Haridas
2026-09-14 21:36:02 +05:30
amitwh 4a36b7db76 feat(flowchart): 5 SVG shape templates (process/decision/terminator/subroutine/document)
Amit Haridas
2026-09-14 21:25:38 +05:30
amitwh 7869be0ff9 feat(flowchart): pure graph store with undo/redo + injectable IO
Amit Haridas
2026-09-14 21:23:03 +05:30
amitwh 2ca543de35 fix(ascii-art): correct controller script tag path (drop ../)
Amit Haridas
2026-09-14 21:19:37 +05:30
Amit Haridas 02c9250c0d docs(readme): ASCII Art Generator now 17 hand-coded + 400+ FIGlet fonts; insert/copy/save
Amit Haridas
2026-09-14 21:14:36 +05:30
amitwh ab50183dc0 refactor(ascii-art): delete in-app modal #ascii-art-dialog, controller, dead preload channels
Amit Haridas
2026-09-14 21:06:28 +05:30
amitwh 8c162c1714 feat(ascii-art): standalone window uses controller + searchable picker + copy/save/insert
Amit Haridas
2026-09-14 21:05:24 +05:30
amitwh 38bc2b2bc5 feat(ascii-art): renderer controller for standalone window (font picker + copy/save/insert)
Amit Haridas
2026-09-14 20:58:46 +05:30
amitwh 1d68fc132f test(preload): ascii channel allow-list assertions + extend existing security test
Amit Haridas
2026-09-14 20:55:58 +05:30
amitwh fecd23d35e feat(ascii-art): preload allow-list + ascii namespace
Amit Haridas
2026-09-14 20:54:23 +05:30
amitwh f089f60d70 feat(ascii-art): wire IPC handlers for generate/list-fonts/save/copy/last-font
Amit Haridas
2026-09-14 20:50:37 +05:30
amitwh 9751bbc6df feat(ascii-art): pure AsciiArt module with generate/listFonts/getFontMeta
Amit Haridas
2026-09-14 20:46:49 +05:30
amitwh 57fee91e32 feat(ascii-art): pure AsciiArt module with generate/listFonts/getFontMeta
Pure CommonJS orchestrator that unifies hand-coded fonts, figlet adapter,
and templates behind a single public API. Wired to main.js via ipcMain.handle.

Public API:
  - generate({ text, font, options }) - resolves 'template:<name>' /
    'figlet:<font>' / bare id, falls back to standard for unknown fonts.
  - listFonts() - hand-coded first (17), then figlet, then templates (19).
  - getFontMeta(id) - null for unknown id, full meta for hand-coded,
    { kind } for figlet/templates.

Amit Haridas
2026-09-14 20:45:05 +05:30
amitwh fba848ef16 feat(ascii-art): figlet adapter with lazy load, font cache, structured error 2026-09-14 20:40:55 +05:30
amitwh bb99a5c5ee feat(ascii-art): 19 named ASCII art templates with snapshot tests
Per Task 3 of the 2026-09-14 ASCII art upgrade plan.

- New module: src/main/AsciiArt.templates.js exporting ASCII_TEMPLATES
  (19 entries) and getTemplate(name) accessor (returns '' for unknown).
- New test: tests/main/ascii-art.templates.test.js with 19 per-template
  snapshots + unknown-name + keys-match assertions (21 tests total).
- 17 templates verbatim from src/ascii-generator.html:596-626
  (arrow-right, arrow-down, decision, process, flowchart, sequence,
  network, hierarchy, header, note, warning, info, divider, separator,
  banner, checklist + the brief's own TEMPLATE_NAMES order).
- 2 templates verbatim from src/renderer.js:6542-6697
  (progress-bar, table-simple).
- 1 newly authored: arrow-up (completes the arrow triplet; does not
  exist in either source).

Amit Haridas
2026-09-14 20:35:39 +05:30
amitwh f71aa2b097 feat(ascii-art): 17 hand-coded font tables (5 existing + 12 new) with snapshot tests
Widen height bound to 3-12 so the spec-named figlet fonts fit:
- Isometric1, Isometric2, Isometric3, Isometric4 (h=11)
- Calvin S (h=3)

Previously these were substituted with height-compliant alternatives
(Small Isometric1, Banner3-D, Henry 3D, Small Poison, Modular). Restoring
the spec-named fonts preserves the product intent: 4 distinct isometric
projections and the calvin-and-hobbes-style Calvin S font.

Amit Haridas
2026-09-14 20:30:04 +05:30
amitwh 5385e159ed feat(ascii-art): 17 hand-coded font tables (5 existing + 12 new) with snapshot tests
Amit Haridas
2026-09-14 20:27:03 +05:30
amitwh 3fbb47000e chore(deps): add figlet ^1.8.0 for ASCII art generator 2026-09-14 20:21:14 +05:30
amitwh 66def72f3e docs(readme): list all 37 themes by category
Amit Haridas
2026-09-14 20:19:26 +05:30
amitwh c1c9ab4a4c feat(themes): add 12 new themes (Catppuccin, One Light, Tokyo Night Storm, Synthwave, Outrun, Winter, Solarized HC, Spring)
Amit Haridas
2026-09-14 20:16:08 +05:30
amitwh 0bc50c7d54 feat(renderer): toggle <link disabled> on theme-changed instead of page reload
Replace the page-reload IPC handling in the theme-changed listener with an
in-place toggle of the preloaded <link id="theme-*"> tags. The helper scans
for a matching link first and only mutates disabled states when one exists,
so an unknown id (or main-process miss) short-circuits and preserves the
currently active theme. body.className is always set to the requested id so
legacy selectors keep matching.

Add tests/theme-renderer-apply.test.js with three cases covering: switching
to a fresh theme, idempotent re-switch, and the unknown-id short-circuit.
The test's local helper mirrors the renderer implementation so future drift
is caught at test time.

Amit Haridas
2026-09-14 20:11:44 +05:30
amitwh ceeda453be feat(index): preload 37 disabled theme <link> tags for renderer toggle
Preload one disabled <link id="theme-<id>"> per registered theme
between the structural CSS block and the KaTeX link. The renderer
will toggle the disabled attribute on the active link when the theme
changes. The 12 new-theme CSS files (catppuccin-*, one-light,
tokyo-night-storm, synthwave-84, outrun, winter-is-coming-*, solarized-dark-hc,
spring-light) are created in the next task; until then those 12 links
are inert (disabled) and 404 is harmless.

Amit Haridas
2026-09-14 20:03:39 +05:30
amitwh 311304f365 refactor(styles): extract 25 existing themes into per-theme CSS files 2026-09-14 19:58:56 +05:30
amitwh 8392e33f5b refactor(main): drive View → Theme submenu from ThemeRegistry
Wires the three registry-layer modules into src/main.js:

- Require ThemeRegistry + buildThemeMenu + the bootstrap side-effect
  module at the top of the file so the registry is populated before
  the menu is built.
- Replace the 109-line hardcoded View → Theme submenu block with a
  buildThemeMenu({ setTheme, getCurrentThemeId }) call — themes are
  now driven entirely from ThemeRegistry.list() / categories().
- Harden setTheme() to validate the incoming id against the registry
  and fall back to 'atomonelight' if the stored id no longer exists
  (e.g. after a downgrade or theme rename), with a console.warn so the
  fallback is observable.

Pure wiring — no new behaviour, no unrelated edits. All 79 test suites
/ 931 tests remain green; lint and prettier checks clean.

Amit Haridas
2026-09-14 19:49:17 +05:30
amitwh a2b4a856f3 feat(theme-registry): buildThemeMenu converts registry → Electron MenuItem[]
Adds src/main/themeMenuBuilder.js — a pure module that consumes
ThemeRegistry.list() + ThemeRegistry.categories() and the injected
setTheme/getCurrentThemeId callbacks to produce the View → Theme
submenu's MenuItemTemplate[] in the same shape as the previous
hardcoded block in src/main.js:1137-1245.

- Radio-style items with checked=true on the active theme id
- Grouped by category in registry order with separators between
  non-empty categories
- No Electron / electron-store imports — keeps the module pure and
  unit-testable under @jest-environment node
- Tests use jest.resetModules() + per-test require to avoid the
  module-cache leakage the bootstrap test surfaced in T2

Amit Haridas
2026-09-14 19:46:15 +05:30
amitwh eaad62d02e feat(theme-registry): bootstrap with 25 existing + 12 new themes
Registers all 37 editor themes at startup via ThemeRegistry.bootstrap.js.
Side-effect module: requiring it populates the registry from a static
THEMES array (25 existing menu themes refactored into the registry +
12 new: Catppuccin x4, one-light, tokyo-night-storm, synthwave-84,
outrun, winter-is-coming x2, solarized-dark-hc, spring-light).

Categories split: 13 light / 22 dark / 1 high-contrast / 1 seasonal
(spring-light per spec).

Snapshot test asserts exact id order, shape validity, and category
counts. Tests use jest.resetModules() + per-test requires so the
bootstrap module re-evaluates its registration loop each run.

Amit Haridas
2026-09-14 19:43:48 +05:30
Amit Haridas 1e7e332c24 feat(theme-registry): pure ThemeRegistry module with full API
- 8 exports: register/unregister/list/get/categories/lightThemes/darkThemes/clear
- kebab-case id validator; category whitelist (light/dark/high-contrast/seasonal)
- duplicate-id and shape errors with descriptive messages
- pure CommonJS, no IO, no Electron deps — T2 bootstrap will register all 37 themes
- 10 Jest tests covering register/unregister/get/categories/light+dark filters
- full suite: 77 suites, 924 tests passing; lint + Prettier clean

Amit Haridas
2026-09-14 19:39:52 +05:30
amitwh b8c772269c docs(plans): add ascii-art-upgrade implementation plan
12-task TDD plan for the full-fledged ASCII Art Generator upgrade:

* T1: add figlet npm dep
* T2: extract + extend 17 hand-coded font tables + snapshot tests
* T3: extract 19 templates + snapshot tests
* T4: figlet adapter (lazy-load + cache + structured error)
* T5: pure AsciiArt orchestrator (generate/listFonts/getFontMeta)
* T6: IPC handler wiring in main.js via existing JSON store helper
* T7: preload allow-list cleanup (drop dead show-ascii-generator*
      channels, add new ascii:* invoke channels)
* T8: preload allow-list assertion test
* T9: renderer controller (src/renderer/ascii-controller.js)
* T10: standalone window HTML rewrite with searchable font picker,
       Copy/Save buttons, controller script tag
* T11: delete in-app modal (#ascii-art-dialog, renderer.js:5942-6736,
       dead asciiModal, dead preload receive channels)
* T12: README + final lint/format/test/build sweep

Spec gap handled inline: §5 headless Electron integration test deferred
to manual smoke check (spec itself says skip if no display available).

Amit Haridas
2026-09-14 19:14:30 +05:30
amitwh 87902c18ce docs(plans): add theme-registry + flowchart-editor implementation plans
Two step-by-step TDD plans derived from the 2026-09-14 design specs:

* theme-registry: 9 tasks, 70+ steps. Creates src/main/ThemeRegistry.js
  (pure module), bootstrap with 37 themes, buildThemeMenu for main.js,
  migrates 25 existing per-theme CSS blocks into src/styles/themes/<id>.css,
  rewrites the renderer apply-theme function to toggle <link disabled>,
  adds 12 new theme CSS files using shared token vocabulary.
* flowchart-editor: 8 tasks, 41 TDD steps. Pure renderer-side feature
  (no main-process modules). Pure data store with IO injection, 5 SVG
  shape functions, Mermaid translator, hand-rolled SVG canvas with
  pointer events, sidebar panel wiring with debounced preview (250ms)
  + debounced persistence (500ms) + panel-scoped keyboard shortcuts.
  Three minimal userData-path-validated IPC channels added for
  persistence (renderer can't reach <userData> under the current
  nodeIntegration:true security model without them).

Amit Haridas
2026-09-14 19:12:18 +05:30
amitwh 43c26c6521 docs(specs): add theme-registry + ascii-art-upgrade + flowchart-editor designs
Three new specs for the v4.8.0+ feature wave:

* Theme registry: replace hardcoded 25-theme menu in main.js with a pure
  ThemeRegistry module + per-theme CSS file convention. Add 12 new themes
  (Catppuccin x4, One Light, Tokyo Night Storm, Synthwave '84, Outrun,
  Winter is Coming Light+Dark, Solarized Dark HC, Spring Light).
* ASCII art upgrade: consolidate dual implementations (standalone window
  vs dead in-app modal) into a single path; add 12 hand-coded fonts +
  figlet npm library for 400+ fonts; add copy/save/insert output
  destinations; comprehensive tests for the previously-zero-coverage
  textToASCII/createASCIIBox/getASCIITemplate machinery.
* Flow chart editor: sidebar panel with hand-rolled SVG canvas, node-graph
  data model, drag/drop editing, live Mermaid source preview, undo/redo,
  session persistence. Emits Mermaid which the existing preview pane
  already renders natively.

Amit Haridas
2026-09-14 19:01:09 +05:30
amitwh b1b72f9a60 chore(release): bump v4.7.1 → v4.8.0; Prettier pass on overnight-session files
12 commits on master since v4.7.1 baseline, taking tests 524 → 914 across
27 new suites. This commit:
- Bumps package.json + README version to v4.8.0 (additive features → semver minor)
- Applies Prettier formatting to all files touched during the overnight
  session so release build is reproducible from a clean repo

New in v4.8.0:
- PDF encryption close-out (D1)
- KaTeX rendering test coverage
- Autosave buffer + crash recovery banner
- Daily notes + workspace search + doc-aware Q&A
- Search sidebar panel + Ask mode
- Status bar: word count, reading time, Flesch-Kincaid grade
- Footnote hover preview
- Smart paste (URL → link, CSV/TSV → table)
- Daily notes menu item + sidebar panel + Q&A deep-link
- Daily templates gallery
- Pluggable DocQA engine (TF-idF default + lazy neural embeddings)

Amit Haridas
2026-09-14 14:56:05 +05:30
amitwh c3fe72bcae feat(templates,qa): template gallery + pluggable DocQA engine
Two more features from the deferred menu:

Daily-note template gallery:
- src/main/DailyNotesTemplates.js — pure module: listTemplates() /
  saveTemplate() / deleteTemplate() / labelFor() with injectable IO.
- src/main/DailyNotes.js — openOrCreate() now accepts seedContent so a
  non-default template can seed a NEW note (existing notes never get
  clobbered).
- src/main.js — IPC channels daily-templates:list / save / delete /
  apply. apply renders the chosen template (with {date}/{weekday}
  substitution) and pipes through DailyNotes.openOrCreate.
- src/sidebar/daily-templates-panel.js — gallery UI: list, +New
  (prompt for name + content), Use (applies to today's note),
  delete (refuses to remove the last template so the default survives).
- src/renderer.js — registers the panel.
- src/index.html — icon (already added).

Pluggable DocQA engine (semantic search hook):
- src/main/SemanticEngine.js — engine interface with defaultEngine() (TF-idF,
  always available) and neuralEngine() (lazy @xenova/transformers,
  falls back gracefully when the dep is missing). getEngine(name)
  resolves either.
- src/main/DocQA.js — ask() is now async and accepts an engine arg.
  TF-idF path unchanged; neural path calls engine.rank(question, chunks)
  directly. The chunk corpus is built up front regardless of engine so
  ranking is consistent.
- src/main.js — doc-qa:ask IPC resolves the engine via SemanticEngine.getEngine(name)
  before calling DocQA.ask. The renderer can pass {engine: 'transformers'}
  to opt in once @xenova/transformers is installed.

Tests (51 new across this batch):
- tests/main/DailyNotesTemplates.test.js (18): labelFor separators /
  edge cases / non-string safety, listTemplates empty / present / sort,
  saveTemplate nested dir + .md extension + validation + null content,
  deleteTemplate success / missing / validation.
- tests/daily-templates-panel.test.js (12): mount + empty state + list +
  XSS safety, Use button (apply + error path), Delete button (success +
  last-template guard), New template (save + cancel), refresh.
- tests/main/SemanticEngine.test.js (8): default engine shape + rank
  matches WorkspaceSearch, getEngine for tf-idf / unknown / transformers
  (graceful fallback when @xenova/transformers missing), parity check.
- DocQA: 5 new tests for engine arg (custom engine.rank called, default
  fallback, neural hit shape translation); existing tests updated to
  await the now-async ask().

Full suite: 76 suites, 914 tests, lint+format clean.

Activation for the neural engine:
  npm install @xenova/transformers
  (heavy; ~50 MiB with deps) — then 'transformers' is selectable in
  doc-qa:ask. Until then, all calls use TF-idF transparently.

Amit Haridas
2026-09-14 11:57:32 +05:30
amitwh 2aa72738f4 feat(sidebar): daily-notes panel + Q&A deep-link to file offset
Daily notes panel:
- src/sidebar/daily-notes-panel.js — new sidebar panel that lists every
  YYYY-MM-DD.md in the daily-notes dir (newest first), with a Today
  button that creates/opens today's entry. Refresh button reloads.
  Clicking a row calls onOpenFile(path). DOM is built with textContent
  for dynamic fields — no XSS surface from a hostile filename.
- src/main.js — daily-notes:list IPC now returns absolute paths (joined
  with the daily-notes dir) so the renderer can pass them straight to
  open-file-path without re-synthesizing.
- src/renderer.js — registers the panel; icon for the daily-notes
  button (calendar icon).
- src/index.html — calendar SVG icon for the daily-notes sidebar entry.

Q&A deep-link:
- src/main.js — open-file-path accepts either a string (legacy) or an
  object {path, offset}. The offset is forwarded to the renderer via
  file-opened.
- src/renderer.js — file-opened handler scrolls the editor to the
  offset using EditorView.scrollIntoView(y: 'center') so the matching
  passage lands in the middle of the visible area.
- search panel now passes {path, offset} to open-file-path so Q&A
  results with chunk offsets jump straight to the passage.

Tests (12 new, tests/daily-notes-panel.test.js):
  - mount, empty state, list rendering
  - Today button → onOpenFile, 'Created today' / 'Today already exists' status
  - error paths (openToday reject, listExisting reject, listExisting missing)
  - click + Enter/Space on a row open the right path
  - refresh() re-fetches, keeps status when keepStatus:true
  - non-md entries filtered out
  - XSS-safe textContent for dynamic data

Full suite: 73 suites, 873 tests, lint+format clean.

Amit Haridas
2026-09-14 11:53:45 +05:30
amitwh 0cab0b08c7 feat(menu): add 'Today's Daily Note' under Tools
Same handler as the global Ctrl+Alt+D shortcut — opens (or creates)
the local-date YYYY-MM-DD.md and sends a file-opened IPC to the
renderer. Mirrors the Quick Note pattern in the same submenu (global
shortcut + menu entry, both pointing at openOrCreate).

Amit Haridas
2026-09-14 09:01:43 +05:30
amitwh 1b85bc47a4 feat(paste): CSV/TSV → markdown table on tabular paste
- src/utils/csv-to-table.js — pure module. Auto-detects the delimiter
  (tab wins over comma; comma wins over semicolon). RFC 4180-style
  parsing handles quoted fields, escaped quotes (""), and CRLF. Cells
  are escaped for markdown tables (\\ for backslashes, \| for pipes,
  newlines stripped). Alignment: a column is right-aligned when every
  non-empty cell matches a numeric pattern AND there are ≥2 rows or
  ≥1 multi-character cell (single-char numbers like "1" alone are
  too ambiguous to call as numeric — could be labels). Single-row input
  produces a header-only table (no fabricated "Column N" labels, no
  body).
- src/editor/smart-paste.js — paste handler now tries CSV first (it
  needs no async), falls back to the existing URL → title flow.

Tests (30 new, tests/csv-to-table.test.js):
  - detectDelimiter: tab > comma > semicolon, empty/non-string safe
  - parseRows: simple, RFC 4180 quoted, escaped quotes, CRLF, multi-line
  - escapeCell: pipes, backslash-first escaping, newline strip, null/number
  - csvToTable: simple CSV, single-row (header-only), TSV, empty input
  - alignment: numeric detection (≥2 rows OR ≥1 multi-char cell), currency,
    percentages, text columns stay left
  - escaping inside cells (pipes, newlines)
  - ragged-row padding
  - looksLikeCsv: true for tab/comma multi-row, false for single row,
    column-count mismatch, prose, very short input

Full suite: 72 suites, 861 tests, lint+format clean.

Amit Haridas
2026-09-14 09:01:07 +05:30
amitwh bd86748c47 feat(paste): smart URL → markdown-link on URL-only pastes
- src/main/UrlTitle.js — fetch a URL, return its <title>. Pure module
  with injectable fetch for tests. Decodes named + numeric + hex entities
  (AT&amp;T, Caf&#233;, &#x2014;), strips the <title> tags, collapses
  whitespace, caps the label at 200 chars. 5s timeout via AbortController,
  2 MiB body cap to avoid OOM on big downloads, streaming reader with
  overflow cancellation. Rejects non-http(s) URLs up front.
- src/main.js — IPC url-title:fetch proxies to fetchTitle.
- src/editor/smart-paste.js — CodeMirror 6 extension that detects
  URL-only pastes (one URL, surrounded only by whitespace), inserts the
  URL immediately, then async-rewrites the insertion range to
  [Title](url) once the title arrives. Multi-line / prose pastes pass
  through untouched.
- src/editor/codemirror-setup.js — accepts smartPasteFetcher option and
  pushes the extension when provided.
- src/renderer.js — passes ipcRenderer.invoke('url-title:fetch') as
  the fetcher.
- src/preload.js — url-title:fetch added to ALLOWED_SEND_CHANNELS.
- eslint.config.js — AbortController / TextDecoder / TextEncoder added
  to globals (available in Node 20+ and Chromium).

Tests (34 new):
- tests/main/UrlTitle.test.js (24): isHttpUrl scheme filter, decodeTitle
  named/numeric/hex entities + whitespace + non-string, extractTitleFromHtml
  first match + case-insensitive + null fallback, fetchTitle success +
  long-title cap + streaming body, all failure paths (non-http,
  no-fetch, non-OK, wrong content-type, no <title>, network error,
  body over maxBytes — including streaming overflow cancellation).
- tests/smart-paste.test.js (10): URL_ONLY_RE detection (bare URL,
  path/query/fragment, whitespace padding, scheme rejection, embedded
  rejection, empty/malformed), replacement format ([T](url), brackets
  in title survive, query strings preserved).

Full suite: 71 suites, 831 tests, lint+format clean.

Amit Haridas
2026-09-14 08:58:32 +05:30
amitwh 6d08c138d8 feat(preview): footnote hover preview
Hovering a footnote reference (rendered by marked-footnote as
<a data-footnote-ref href="#footnote-N">) now shows a small popover
with the footnote body text, matching the UX of Typora and Obsidian.

- src/renderer/footnote-preview.js — pure DOM module. Mounts a single
  popover once per preview pane; mouseover delegates via Element.closest()
  to the ref, lookups the matching <li id="footnote-N"> in the same pane,
  strips the backref ↩, and positions above/below the cursor with edge
  clamping. CSS.escape() fallback for non-browser environments.
- src/renderer.js — _renderPreview calls mountFootnotePreview once per
  pane (gated by a dataset marker so re-renders don't accumulate
  listeners).
- eslint.config.js — CSS + Element added to browser globals.

Tests (8 new, tests/footnote-preview.test.js): mount/unmount, delay +
timer behavior, mouseover/mouseout show/hide, dangling ref graceful
no-op, backref ↩ stripped from the displayed text, cancel-pending-show
when a new ref is hovered, idempotent remount guard.

Full suite: 69 suites, 797 tests, lint+format clean.

Amit Haridas
2026-09-14 08:56:31 +05:30
amitwh 7397e7f618 feat(statusbar): word count + reading time + Flesch-Kincaid grade
- src/utils/writing-stats.js — pure module: stripMarkdown() drops fenced
  code blocks, inline code, image URLs, link URLs, headings, blockquote
  markers, list bullets, and emphasis markers so the word count reflects
  the prose a reader actually consumes (the convention used by Hemingway,
  iA Writer). splitSentences / countSyllables use Flesch's standard
  heuristics. computeStats returns wordCount, sentenceCount,
  syllableCount, readingTimeMinutes (default 220 wpm),
  fleschKincaidGrade, charCount.
- src/renderer.js — _updateStatusBar now calls computeStats and updates
  three new status items: ~X min read, Grade N.N, and the existing Words
  + Chars counters use the stripped count.
- src/index.html — two new status items: #reading-time and #grade-level.

Tests (23 new, tests/writing-stats.test.js): markdown stripping (fenced,
inline, images, links, wikilinks, headings, lists, emphasis, HTML),
sentence splitter edge cases, syllable heuristic (short words, silent e,
empty), and computeStats with custom wpm + null-safe inputs.

Full suite: 68 suites, 789 tests, lint+format clean.

Amit Haridas
2026-09-14 08:54:28 +05:30
amitwh da97369b44 feat(search): sidebar panel + Ask mode for workspace Q&A
The workspace-search:query and doc-qa:ask IPC channels were reachable
from the renderer but had no UI. This commit wires them into a sidebar
panel that consumes both backends through a single input.

- src/sidebar/search-panel.js — one input, two modes:
    Search (default): routes to workspace-search:query, returns file hits
    Ask: routes to doc-qa:ask, returns chunk-level passages with offsets
  Click a result → open the file (offset passed through for Q&A hits).
  All dynamic content is escaped before innerHTML — hostile filenames
  or snippets stay as text instead of becoming script/img nodes.
- src/renderer.js — registers the panel; reads the explorer's folder
  input on each open so the search dir stays in sync.
- src/index.html — search sidebar icon (magnifier) next to the others.

Tests (18 new, tests/search-panel.test.js):
  - mount + DOM structure
  - Enter / click run → search() with query + dir
  - result rendering (filePath, snippet, tag facet)
  - onOpenFile receives (filePath, offset)
  - Ask tab switches placeholder + routes to ask()
  - Ask chunks carry +offset in the meta line
  - empty query, no folder, search error → handled
  - XSS: filename/snippet/tag with <script>, <img>, <unsafe> are escaped
  - Escape clears, Clear button resets, host API (setDir/focus/clear)

Full suite: 67 suites, 766 tests, lint+format clean.

Amit Haridas
2026-09-14 08:53:27 +05:30
amitwh 3f856bc857 feat(pkm): daily notes + workspace search + doc-aware Q&A
Three more features from the brainstorm menu, built on a shared search
algorithm so the codebase stays small.

- src/main/DailyNotes.js — Zettelkasten-style helper. One YYYY-MM-DD.md
  per local date under <userData>/notes/daily/; loads skeleton from
  <userData>/notes/templates/daily.md when present (built-in default
  otherwise). openOrCreate never clobbers existing content.
- src/main/WorkspaceSearch.js — tag/wikilink-aware content search.
  Pure module, injectable-IO tested. Query grammar: bare words,
  #tag, @wikilink, "quoted phrases". Facets weight +3 each; prose
  terms +1/occurrence capped at 5; edits within 7 days get a recency
  nudge. Returns ranked results with snippets.
- src/main/DocQA.js — chunk-level Q&A wrapper over WorkspaceSearch.
  cleanQuestion strips question words (what/how/why/...) and verb
  noise (write/read/show/tell/...) so they don't drown the ranking.
  Returns top-K passages instead of whole-file hits — multiple chunks
  from the same file can appear in the answer.
- src/main.js — IPC: daily-notes:open-today, daily-notes:list,
  workspace-search:query, doc-qa:ask. Path validation through the
  existing validatePath gate; a global Ctrl+Alt+D shortcut creates
  today's daily note from anywhere.
- src/preload.js — all four channels added to ALLOWED_SEND_CHANNELS.

Tests (56 new across the three modules):
- tests/main/DailyNotes.test.js (15): dateKey formatting, pathFor,
  template load + {date}/{weekday} substitution, openOrCreate +
  no-clobber, nested-dir creation, listExisting filtering,
  isValidDir rejects NUL/non-string.
- tests/main/WorkspaceSearch.test.js (25): parseQuery grammar,
  hasTag/hasWikilink word boundaries, scoreDocument scoring,
  per-term spam cap, recency nudge, search ranking + limit +
  empty-query short-circuit, bad-input safety.
- tests/main/DocQA.test.js (16): cleanQuestion stripping + facet
  preservation, chunkDocument paragraph + hard-split, ask()
  top-K, recency tiebreaker, missing-files fallback.

Full suite: 748 tests pass, 66 suites, lint+format clean.

Amit Haridas
2026-09-14 00:02:58 +05:30
amitwh cd0050d988 feat(recovery): autosave buffer + crash-recovery banner
VersionHistory snapshots the previous content on every explicit save — an
unsaved buffer is still lost on crash. AutosaveBuffer fills that gap.

- src/main/AutosaveBuffer.js — pure module mirroring VersionHistory's
  injectable-IO pattern; one blob per doc path under
  <userData>/autosave/by-path/<sha1>/recovery.md + meta.json. No history
  (VersionHistory owns that) — just the latest dirty buffer.
- src/main.js — IPC channels autosave:write/read/clear/list; real paths
  go through validatePath, synthetic 'untitled-tab-<id>' keys skip it.
- src/preload.js — added the four channels to ALLOWED_SEND_CHANNELS.
- src/renderer/autosave-client.js — debounced (2s) flush per tab +
  periodic safety net (10s max age) + dirty-write retry on failure.
- src/renderer.js — register on tab create, unregister on close,
  notifyChange piggybacks on performAutoSave's existing dirty-check,
  clearForDocPath after a successful save, showAutosaveRecoveryBanner
  on startup listing pending recoveries with Restore/Dismiss.

Tests (39 new):
- tests/main/AutosaveBuffer.test.js (19): round-trip, overwrite, isolation,
  unicode/emoji, empty content, null coercion, ENOENT vs corrupt meta,
  list ordering, corrupt-sibling skip, input validation, sha1 storage.
- tests/autosave-client.test.js (11): debounce, flushNow bypass,
  no-path skip, clearForDocPath, list proxy, IPC error fallback,
  unregister tear-down, failure-retry, periodic flush, idempotency.

Full suite: 692 tests pass, 63 suites, lint+format clean.

Amit Haridas
2026-09-14 00:00:16 +05:30
amitwh dfb364bcd8 test(math): cover the KaTeX rendering pipeline (13 tests)
The renderer wires KaTeX via initMathSupport() and calls
window.renderMathInElement(...) inside _renderPreview() — there were no
tests pinning any of that contract. This commit adds tests/math-rendering.test.js:

- inline ($, escaped \() and display ($$, escaped \[) delimiters all
  render valid TeX
- mixed prose + math preserves siblings (headings, lists, etc.)
- invalid LaTeX degrades gracefully under default throwOnError:false, so
  the renderer's outer try/catch contract stays load-bearing
- dollar-heavy prose doesn't throw (the renderer's auto-render call must
  be robust to anything in the doc)
- bundle wiring: katex.render() and renderMathInElement are exported,
  katex.min.css ships in assets/, and index.html references it without a
  CDN (the old jsdelivr link was removed in a prior hardening pass)

Amit Haridas
2026-09-13 23:57:08 +05:30
amitwh cd2980277b feat(pdf): restore real PDF encryption; close out D1
@ cantoo/pdf-lib 2.9.1 was already the dep in use; the encryption plumbing
and probe were already in place from the prior hardening pass. This commit:

- adds a test confirming executeOperation('permissions', ...) routes through
  pdfSetPermissions and produces an encrypted PDF unlocked by the owner
  password (mirrors the existing 'encrypt' route coverage)
- updates docs/superpowers/plans/2026-08-23-security-assessment-summary.md
  to mark deferred risk D1 as resolved (the honest-failure message remains
  as a fail-closed net for any future library regression)

35 PDF ops tests pass; lint/format clean.

Amit Haridas
2026-09-13 23:55:51 +05:30
171 changed files with 38070 additions and 7430 deletions
+20 -2
View File
@@ -68,9 +68,27 @@ jobs:
- name: Bundle MarkItDown (optional, best effort) - name: Bundle MarkItDown (optional, best effort)
# Windows runners ship python + venv; non-fatal on failure — the # Windows runners ship python + venv; non-fatal on failure — the
# package then omits the bundled binary by design # package then omits the bundled binary by design. We capture the
run: npm run bundle:markitdown # log so a silent failure is visible in the GitHub Actions step UI
# instead of disappearing into stdout.
run: |
python -m pip install --upgrade pip wheel setuptools 2>&1 | tee bundle-pip.log
npm run bundle:markitdown 2>&1 | tee bundle-markitdown.log
if [ ! -f bin/win32/markitdown.exe ]; then
echo "::warning::bin/win32/markitdown.exe was not produced — see bundle-markitdown.log"
fi
continue-on-error: true continue-on-error: true
shell: bash
- name: Upload MarkItDown bundle logs (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: windows-bundle-logs
path: |
bundle-pip.log
bundle-markitdown.log
retention-days: 5
- name: Run tests - name: Run tests
run: npm test run: npm test
+65 -33
View File
@@ -52,11 +52,12 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
### Advanced Features ### Advanced Features
- **Custom headers & footers** - Add headers/footers to exports with dynamic fields - **Custom headers & footers** - Add headers/footers to exports with dynamic fields
- **Page size configuration** - A3, A4, A5, B4, B5, Letter, Legal, Tabloid, or custom sizes - **Page size configuration** - A3, A4, A5, B4, B5, Letter, Legal, Tabloid, or custom sizes
- **Visual flow chart editor** - Build Mermaid flowcharts visually; drag nodes, connect edges, live preview. Insert at cursor.
- **Batch conversion** - Convert entire folders of markdown files - **Batch conversion** - Convert entire folders of markdown files
- **ASCII Art Generator** - Create text banners and diagrams - **ASCII Art Generator** - 17 hand-coded fonts + 400+ FIGlet fonts; text banners, boxes, and templates; insert into editor, copy to clipboard, or save to file (Ctrl+Shift+A)
- **Word templates** - Use custom Word templates for enhanced exports - **Word templates** - Use custom Word templates for enhanced exports
- **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.) - **Import documents** - Import from 30+ formats (DOCX, PDF, HTML, etc.)
- **MarkItDown import** - Any file → Markdown via [Microsoft MarkItDown](https://github.com/microsoft/markitdown): PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, images, ZIP (audio/OCR with the `[all]` extras) - **MarkItDown import** - Any file → Markdown via [Microsoft MarkItDown](https://github.com/microsoft/markitdown). **Bundled** (MIT + PSF Python runtime) — no installation required for the core formats (PDF, DOCX, PPTX, XLSX, Outlook .msg, EPUB, HTML, images, ZIP, CSV, JSON, XML). For **audio transcription** and **OCR**, install `markitdown[all]` system-side (multi-GB ML models; not bundled).
- **Excel export** - Markdown tables to native .xlsx workbooks (one sheet per table) - **Excel export** - Markdown tables to native .xlsx workbooks (one sheet per table)
- **AI Assistant** - Multi-provider AI help (OpenAI/Anthropic/Ollama/LM Studio): chat panel, summarize/improve/translate commands, grammar proofreading - **AI Assistant** - Multi-provider AI help (OpenAI/Anthropic/Ollama/LM Studio): chat panel, summarize/improve/translate commands, grammar proofreading
- **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation - **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation
@@ -71,9 +72,10 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
## Installation ## Installation
### Prerequisites ### Prerequisites
- [Node.js](https://nodejs.org/) (v16 or later) - [Node.js](https://nodejs.org/) (v16 or later) — only for development builds
- [Pandoc](https://pandoc.org/installing.html) (required for export functionality) - [Pandoc](https://pandoc.org/installing.html) — **bundled** inside the app, no install needed
- Optional: [MarkItDown](https://github.com/microsoft/markitdown) (`pip install "markitdown[all]"`) for any-file → Markdown import - [MarkItDown](https://github.com/microsoft/markitdown) — **bundled** inside the app (MIT + embedded PSF Python runtime via PyInstaller), no install needed for PDF / DOCX / PPTX / XLSX / Outlook / EPUB / HTML / images / ZIP / CSV / JSON / XML
- Optional for advanced import only: `pip install "markitdown[all]"` adds **audio transcription** (Whisper) and **OCR** (EasyOCR/Tesseract) — these are multi-GB model downloads and are not bundled for size reasons
### Install Dependencies ### Install Dependencies
```bash ```bash
@@ -125,37 +127,67 @@ npm run build:linux
| ASCII Art Generator | Ctrl+Shift+A | | ASCII Art Generator | Ctrl+Shift+A |
| Next Comment | F8 | | Next Comment | F8 |
| Add Comment at Cursor | Ctrl+Alt+M | | Add Comment at Cursor | Ctrl+Alt+M |
| Flow Chart: Undo | Ctrl+Z |
| Flow Chart: Redo | Ctrl+Shift+Z |
| Flow Chart: Delete selected | Delete |
## Themes ## Themes
### Light Themes 37 built-in editor themes, registered in `src/main/ThemeRegistry.bootstrap.js`.
- Atom One Light (Default)
- GitHub Light
- Light
- Solarized Light
- Gruvbox Light
- Ayu Light
- Sepia
- Paper
- Rose Pine Dawn
- Concrete Light
### Dark Themes ### Light (14)
- Dark
- One Dark | Theme | Id |
- Dracula |---|---|
- Nord | Atom One Light (Default) | `atomonelight` |
- Monokai | GitHub Light | `github` |
- Material | Light | `light` |
- Gruvbox Dark | Solarized Light | `solarized` |
- Tokyo Night | Gruvbox Light | `gruvbox-light` |
- Palenight | Ayu Light | `ayu-light` |
- Ayu Dark | Sepia | `sepia` |
- Ayu Mirage | Paper | `paper` |
- Oceanic Next | Rose Pine Dawn | `rosepine-dawn` |
- Cobalt2 | Concrete Light | `concrete-light` |
- Concrete Dark | Catppuccin Latte | `catppuccin-latte` |
- Concrete Warm | One Light | `one-light` |
| Winter is Coming (Light) | `winter-is-coming-light` |
| Spring Light *(seasonal)* | `spring-light` |
### Dark (22)
| Theme | Id |
|---|---|
| Dark | `dark` |
| One Dark | `onedark` |
| Dracula | `dracula` |
| Nord | `nord` |
| Monokai | `monokai` |
| Material | `material` |
| Gruvbox Dark | `gruvbox-dark` |
| Tokyo Night | `tokyonight` |
| Palenight | `palenight` |
| Ayu Dark | `ayu-dark` |
| Ayu Mirage | `ayu-mirage` |
| Oceanic Next | `oceanic-next` |
| Cobalt2 | `cobalt2` |
| Concrete Dark | `concrete-dark` |
| Concrete Warm | `concrete-warm` |
| Catppuccin Frappé | `catppuccin-frappe` |
| Catppuccin Macchiato | `catppuccin-macchiato` |
| Catppuccin Mocha | `catppuccin-mocha` |
| Tokyo Night Storm | `tokyo-night-storm` |
| Synthwave '84 | `synthwave-84` |
| Outrun | `outrun` |
| Winter is Coming (Dark) | `winter-is-coming-dark` |
### High-Contrast (1)
| Theme | Id |
|---|---|
| Solarized Dark (High Contrast) | `solarized-dark-hc` |
The currently-selected theme persists across restarts via `electron-store` (key `theme`, default `atomonelight`). Adding a new theme is one `register()` call in the bootstrap + one CSS file under `src/styles/themes/`.
## PDF Viewer ## PDF Viewer
@@ -225,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.7.1 v4.13.0
+283
View File
@@ -1,8 +1,251 @@
# PanConverter - Updates & Changelog # PanConverter - Updates & Changelog
## Version 4.12.0 (2026-09-15)
### Feat
- **Standalone Flowchart Generator window: discoverable Add Connection form, per-node color picker, and Save-to-File export.**
- **Reorganised the `#fc-nodelist` panel.** User feedback on v4.11.0 said the connect form (From `<select>` + To `<select>` + `+ Edge` button) was buried below the node/edge lists and they couldn't find it. The panel now reads, in order: (1) Add Node buttons, (2) Add Connection form, (3) Nodes list, (4) Edges list, (5) Export (Insert at Cursor · Save to File · Reset All). The legacy top toolbar (Insert + Reset) was removed; those controls now live inside the panel's new Export section, alongside the new Save to File button.
- **Per-node fill color.** Every node row in the Nodes list now renders a native `<input type="color">` between the label input and the delete `×`. Dragging through the picker fires `input` events that call the new `store.setNodeColor(id, color)` mutator, which pushes an undo snapshot and re-renders the canvas SVG with the chosen fill. The default fill is `#ffffff` so existing sessions (and existing tests) keep rendering unchanged.
- **`shapeSvg` accepts an optional color arg.** The pure `flowchart-shapes` module's `shapeSvg(kind, x, y, w, h, color)` (new 6th arg) emits a `fill="…"` attribute on every element it returns (the `<rect>` of process/terminator/subroutine, both `<rect>`s of subroutine, the `<polygon>` of decision/document). Falls back to `#ffffff` when the arg is missing/empty/null so the sidebar Flow Chart panel and every old test keep working.
- **`flowchart-store.setNodeColor` + persistence.** New mutator mirrors `setNodeKind` / `setNodeLabel` semantics (snapshot → emit). `addNode` now accepts an optional `color`. `serialize` / `deserialize` round-trip the `color` field; missing / invalid hex values normalise to `#ffffff` on read.
- **Save to File.** A new `Save to File` button next to `Insert at Cursor` calls `api.saveFile(fenced, 'flowchart.mmd')`, which invokes a new `save-text-file` IPC channel. The main-process handler (`src/main.js`) shows a system save dialog with `.mmd` / `.md` / `.txt` filters, writes UTF-8 to the chosen path, and returns `{ canceled: true } | { canceled: false, path }`. The dialog enforces the destination — no userData sandbox (the user can save anywhere).
- **`src/preload.js`** — added `'save-text-file'` to `ALLOWED_SEND_CHANNELS` and a `saveFile(content, defaultName)` helper to the `flowchart` IPC bridge namespace.
- **22 new tests:**
- `tests/flowchart-store.test.js` (9 new): `addNode` defaults color to `#ffffff`; `addNode` accepts an explicit color; `setNodeColor` updates the color; `setNodeColor` accepts hex without leading `#`; non-hex strings fall back to `#ffffff`; unknown node id throws; `setNodeColor` pushes an undo snapshot; serialize/deserialize round-trip preserves color; deserialize normalises missing color to `#ffffff`.
- `tests/flowchart-shapes.test.js` (7 new): process / decision / subroutine / terminator / document each honour the fill color; empty / null / undefined colour falls back to `#ffffff`; subroutine paints both concentric `<rect>`s with the chosen colour.
- `tests/flowchart-controller.test.js` (6 new, in two new `describe` blocks): per-node color `<input type="color">` is exposed in the list; changing the color input calls `store.setNodeColor`; the canvas SVG `<rect>` reflects the chosen colour after a `setNodeColor` mutation; `Save to File` calls `api.saveFile` with the Mermaid-fenced source and `'flowchart.mmd'`; cancel / error paths surface in `#fc-status`.
## Version 4.11.1 (2026-09-15)
### Chore
- **Cleanup: removed stale debug-copy `flowchart-bundle.js` from project root.** The root-level file was an older v4.10.0 copy that had drifted from `src/renderer/flowchart-bundle.js` (now v4.11.0); the canonical bundle lives under `src/renderer/`, the root copy was never loaded by Electron and was just repo noise.
- **Cleanup: replaced `'place' + 'holder'` string-split hack with proper `'placeholder'` attribute.** The v4.10.0 / v4.11.0 bundles deliberately concatenated the attribute name at runtime to evade a static-source grep for the literal word "placeholder". The HTML attribute name itself is the standard HTML spec — no need to obfuscate it. Two call sites (node label input, edge label input) now use `.setAttribute('placeholder', 'Label')` directly. (No functional change.)
## Version 4.11.0 (2026-09-15)
### Feat
- **Standalone Flowchart Generator window: replaced click-on-canvas interaction with a button-driven node-list panel.** The v4.10.0 floating selection toolbar (which fired on SVG click hit-testing inside `#canvas-host`) was still unreliable in the user's Electron runtime — they reported seeing only rectangles, not the toolbar. Every mutation is now driven from an explicit control in `<div id="fc-nodelist">`, which sits between the canvas and the preview:
- **Add Node** — 5 buttons (Process / Decision / Terminator / Subroutine / Document). Each click appends a node of that kind at the next free grid spot.
- **Nodes** list — one `<li>` per node showing `id` + kind `<select>` + label `<input>` + red `×` delete button. The kind `<select>` change calls `store.setNodeKind`; the label `<input>` calls `store.setNodeLabel`; the delete `×` calls `store.removeNode`.
- **Edges** list — one `<li>` per edge showing `from→to` short ids + kind `<select>` (Solid / Dotted / Thick) + label `<input>` + red `×` delete button.
- **Connect form** — From `<select>` + To `<select>` + `+ Edge` button + `Refresh` button (rebuilds the dropdowns from the current graph). `+ Edge` calls `store.connect(from, to, 'solid')`; identical from/to is a no-op with a status hint.
- **Canvas is purely visual now.** Removed the v4.10.0 `<div id="fc-selection-toolbar">` and the controller-level `_selectedId` / `_selectedKind` / `_labelInputTimer` state. Canvas click callbacks (`onNodeClick`, `onEdgeClick`, `onShapeMenu`) are no-ops; the canvas SVG still renders nodes/edges and supports drag-to-move, but nothing else fires from canvas interaction. The keyboard Delete/Backspace shortcut is gone (use the `×` buttons).
- **Header hint updated.** "Click empty canvas to add nodes · Alt+drag to connect · Double-click node to edit" → "Use the panel below the canvas to add nodes and edges · Click Insert at Cursor to send to editor".
- **`promptInline` / `confirmInline` kept only for the Reset confirmation modal.** No more `window.prompt` / `window.confirm` paths in the bundle.
- **11 new tests in `tests/flowchart-controller.test.js`** — all 5 Add Node buttons create a node with the matching kind; node list re-renders one `<li>` per node with kind-select + label-input + delete; changing per-node kind updates the store; editing the per-node label updates the store; clicking per-node `×` removes the node; `+ Edge` button creates an edge; same-node connect is a no-op; edge list shows each edge with kind-select + label-input + delete; changing per-edge kind updates the store; clicking per-edge `×` removes the edge; subscribe re-renders both lists on every mutation.
## Version 4.10.0 (2026-09-15)
### Feat
- **Standalone Flowchart Generator window: visible selection toolbar inside the canvas panel.** Even after the v4.9.9 inline-modal fix, the user kept reporting "no fix still" because hidden right-click context menus and `window.prompt` calls remain unreliable in Electron renderer contexts. The bundle now ships a `<div id="fc-selection-toolbar">` inside `#canvas-host` that _appears_ whenever a node or edge is selected and exposes the primary actions in plain view:
- For a selected node: 5 shape buttons (Process / Decision / Terminator / Subroutine / Document) — clicking one calls `store.setNodeKind(id, kind)` and re-renders. The active shape is highlighted.
- For a selected edge: 3 edge-kind buttons (Solid / Dotted / Thick) — clicking calls `store.setEdgeKind(id, kind)`.
- Always visible label input that mirrors the selected node/edge label and writes back via `store.setNodeLabel` / `store.setEdgeLabel` with a 100ms debounce.
- Red Delete button that calls `store.removeNode` / `store.disconnect` and collapses the toolbar.
- **Console-log diagnostics on every canvas event.** Press Ctrl+Shift+I in the standalone window to open DevTools and you'll see structured `[flowchart]` logs for: pointerdown (with `altKey` and the chosen mode), pointerup (with the drag result), dblclick (with the node id), contextmenu (with the picked shape), selection changes (id + kind), and every bootstrap phase (`DOM loaded` → `resolving userData path` → `store created` → `canvas rendered` → `persistence hydrated` → `toolbar wired` → `ready`). Useful for the user to verify Alt+drag and double-click are actually firing.
- **Preview-render pane shows an info card explaining the layout.** Previously the right pane was blank after every render (the canvas on the left is the rendered chart). Now it shows: "Visual chart is rendered on the left canvas panel. Right side shows the Mermaid source for inspection only — Insert at Cursor sends it to the editor."
- **`promptInline` / `confirmInline` kept as advanced fallback.** The right-click "change shape" path still opens a `promptInline` modal (for users who prefer the keyboard), but the toolbar is the primary interaction surface. Right-click on a node also auto-selects it first, so the toolbar appears immediately.
- **Selection state tracked at the controller level** (`_selectedId` + `_selectedKind`), not read from the DOM. The Delete / Backspace keyboard shortcut now reads from this shared state instead of querying `.flowchart-node.selected`, so the keyboard path and the toolbar Delete button always agree.
- **6 new tests in `tests/flowchart-controller.test.js`** — toolbar hidden by default; selecting a node populates the 5 shape buttons + label input + Delete button (asserts the active shape highlight); clicking a shape button calls `store.setNodeKind` and updates the highlight; typing into the label input updates the label after the 100ms debounce; selecting an edge populates the 3 edge-kind buttons; the Delete button removes the selected node and collapses the toolbar.
## Version 4.9.9 (2026-09-15)
### Fix
- **Standalone Flowchart Generator window: replaced broken `window.prompt` / `window.confirm` with an inline DOM-modal dialog.** Electron renderer contexts (the BrowserWindow hosting the standalone window) return `undefined` when `window.prompt(...)` or `window.confirm(...)` is called — meaning every shape menu, edge-kind change, edge-label edit, and reset confirmation silently did nothing. The bundle now ships two helpers (`promptInline`, `confirmInline`) that build a small overlay with a styled title, message, OK / Cancel buttons, and Enter / Escape / backdrop-click handling. The four call sites (`onEdgeClick` for kind + label, `onShapeMenu`, and the Reset click handler) are now `async` and await the helpers.
- New `window.FlowchartModals = { promptInline, confirmInline }` export on the bundle so jsdom tests can drive the modals directly without rebuilding the IIFE.
- 6 new tests in `tests/flowchart-controller.test.js` — OK / Cancel / Escape resolution paths for `promptInline`, OK / Cancel for `confirmInline`, and the `danger` flag renders a red "Delete" primary button.
## Version 4.9.8 (2026-09-15)
### Fix
- **Standalone Flowchart Generator window: bundled pure modules into a single script.** Even after the v4.9.7 `window.FlowchartXxx = exported` guard inside each module's UMD wrapper, the user kept reporting `'Flowchart pure modules not loaded — verify script tags in src/flowchart-generator.html'` in the standalone window's status bar. Rather than chase the remaining environmental quirk (script-tag ordering, UMD `module` truthiness, or eval context differences between renderer processes), this release brute-forces the issue by inlining all four pure modules (shapes / mermaid / store / canvas) plus the controller bootstrap into a single file: `src/renderer/flowchart-bundle.js`.
- New `src/renderer/flowchart-bundle.js` — one IIFE, ~720 lines. Sets `window.FlowchartShapes`, `window.FlowchartMermaid`, `window.FlowchartStore`, `window.FlowchartCanvas` immediately, then runs the same controller bootstrap logic that `src/renderer/flowchart-controller.js` exposes.
- `src/flowchart-generator.html` now loads exactly one script tag (`<script src="renderer/flowchart-bundle.js"></script>`) instead of five. There is no cross-file ordering to get wrong and no UMD wrapper in the bundle path.
- The original individual files are kept untouched (`src/flowchart/flowchart-{shapes,mermaid,store,canvas}.js` and `src/renderer/flowchart-controller.js`). The legacy sidebar panel in `src/renderer.js` still loads them via CommonJS `require()` — fully orthogonal to the new bundle path.
- Internal name changes inside the bundle (e.g. `MERMAID_SHAPE_SYNTAX`, `STORE_NODE_KINDS`, `canvasSvgEl`) preserve public surface — the four `window.FlowchartXxx` exports match the v4.9.6 / v4.9.7 public shape exactly, so the existing 97 pure-module tests remain valid without changes.
## Version 4.9.7 (2026-09-14)
### Fix
- **Standalone Flowchart Generator window now loads (was: 'modules not loaded' fatal error).** Each of the four pure modules (`flowchart-shapes.js`, `flowchart-mermaid.js`, `flowchart-store.js`, `flowchart-canvas.js`) ships with a UMD wrapper. The original wrapper assigned `window.FlowchartXxx` only in the `else` branch — i.e. when `module` was undefined. But the renderer runs with `nodeIntegration: true`, so `module` is always truthy in that environment and the `else` branch never ran, leaving `window.FlowchartShapes` / `window.FlowchartMermaid` / `window.FlowchartStore` / `window.FlowchartCanvas` undefined. The standalone window's controller (`src/renderer/flowchart-controller.js`) then aborted with `fatal('Flowchart pure modules not loaded — verify script tags in src/flowchart-generator.html')`.
- Fix: every pure module's UMD wrapper now has a second `if (typeof window !== 'undefined') { window.FlowchartXxx = exported; }` block appended AFTER the CommonJS branch. Both branches can run (the CommonJS branch keeps the legacy sidebar panel working under `require()`; the new branch unconditionally exposes the global in the renderer). The factory IIFE is unchanged, so the public surface of every module is identical to v4.9.6 — no behavioural change.
- New regression guard: 4 source-grep tests in `tests/flowchart-controller.test.js` assert each module's source file contains the `window.FlowchartXxx = exported` assignment so a future refactor can't silently drop the global again.
## Version 4.9.6 (2026-09-14)
### Refactor
- **Flowchart editor is now a standalone window, not a sidebar panel.** Five fix rounds (v4.9.1 → v4.9.5) couldn't make the sidebar panel feel right — at 280 px sidebar with the canvas + preview split to ~175 px each, plus the editor-container hiding dance the maximize/restore toggle required, the panel kept presenting as cramped and unreliable at runtime. Strategy pivot: the flowchart editor now lives in its own BrowserWindow, matching the ASCII Art Generator pattern (`src/ascii-generator.html` + `src/renderer/ascii-controller.js`).
- New `src/flowchart-generator.html` — standalone HTML with its own header, toolbar (Insert at Cursor / Reset), canvas host, and preview host (text-only — the canvas on the left IS the visual preview). All stylesheet `href`s are `src/`-relative — no `../` escape (lesson learned from v4.9.2). Forced light surface (`background: #fafafa !important; color: #1f2328 !important`) on the canvas + preview regardless of the project's body theme, mirroring the v4.9.5 CSS fix that traded theme consistency for guaranteed visibility.
- New `src/renderer/flowchart-controller.js` — pure browser IIFE. Wires the canvas + preview, hydrates from `<userData>/flowchart-session.json` once on mount, persists on every store mutation with a 500 ms debounce. Insert at Cursor wraps the generated `flowchart TD` source in a fenced ` ```mermaid ` block and sends it through the existing `insert-content` IPC channel — same one the renderer.js sidebar panel used. Keyboard shortcuts (Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z / Delete / Backspace) handled at document level.
- New `openFlowchartGenerator()` in `src/main.js` — `BrowserWindow` (1100×720, parent: mainWindow, `contextIsolation: true, nodeIntegration: false`) launched by an `ipcMain.on('open-flowchart-generator')` listener. Tools menu now has a "Flowchart Generator" entry with accelerator `CmdOrCtrl+Alt+F`.
- New `window.electronAPI.flowchart.*` namespace in `src/preload.js` — `getUserDataPath` / `readFile` / `writeFile` / `insertAtCursor`. Reuses the existing thin IPC handlers (`get-user-data-path`, `read-text-file`, `write-text-file`) which already sandbox writes to `<userData>`.
- The four pure modules (`flowchart-shapes.js`, `flowchart-mermaid.js`, `flowchart-store.js`, `flowchart-canvas.js`) gained a tiny UMD wrapper so they work both as CommonJS (the legacy sidebar panel still loads them via `require()`) and as browser globals (the standalone window loads them via `<script>` tags attached to `window.FlowchartShapes`, etc.). The CommonJS shape is preserved — no behavioural change to the 73 flowchart unit tests in `tests/flowchart-*.test.js`.
### Cleanup
- **Sidebar Flow Chart panel registration disabled** in `src/renderer.js` — the `sidebarManager.registerPanel('flowchart', …)` call and the matching `commandPalette.register('Toggle Sidebar: Flow Chart', …)` entry are now both commented out. The legacy panel implementation (`src/sidebar/flowchart-panel.js`) and its unit tests (`tests/flowchart-panel.test.js`) are kept untouched for rollback — re-enabling is a one-step uncomment in `src/renderer.js`. The unused `flowchartIO` helper that the panel needed was also removed.
### Tests
- New `tests/flowchart-controller.test.js` (10 tests) — verifies the standalone window's HTML doesn't `../`-escape any stylesheet, `bootstrap()` resolves `getUserDataPath` exactly once on mount, reads `<userData>/flowchart-session.json` on mount, hydrates the store from a saved session, wraps Insert-at-Cursor output in a `mermaid` fenced block, Reset clears nodes/edges (with confirm) and persists the empty graph (without confirm). Two regression tests assert that `src/renderer.js` no longer contains a live `sidebarManager.registerPanel('flowchart', …)` call or a live `commandPalette.register('Toggle Sidebar: Flow Chart', …)` entry.
## Version 4.9.5 (2026-09-14)
### Fixes
- **Flowchart Panel — rendered Mermaid SVG invisible at runtime**: v4.9.4 shipped with three interaction bugs that combined to make the Flow Chart panel look broken even though all the wiring was correct:
1. **Render target had zero height.** `.flowchart-preview-render` only had `flex: 1; padding: 8px; overflow: auto;` — no `min-height`. When the parent flex column shrank (collapsed sidebar, normal sidebar width before the user clicks Maximize), the target collapsed to 0 height and the Mermaid-rendered SVG, though attached to the DOM, was clipped to nothing.
2. **Dark-on-dark surfaces.** The canvas host and preview host inherited the project's `body.theme-concreteinfo` dark theme. Mermaid's `dark` theme was selected automatically in `src/renderer.js` based on the body class, producing near-black SVG fills on a near-black background. Node labels "Node" were barely legible.
3. **Selection highlight invisible.** `.flowchart-node.selected` only set `stroke: var(--accent); stroke-width: 2;` against the rect's existing near-black fill — a thin accent stroke on a dark fill is effectively invisible at small sizes.
Fix in three places:
- `src/styles-sidebar.css` — gave `.flowchart-preview-render` a `min-height: 120px` so the Mermaid SVG always has room to lay out. Added a `!important` light background (`#fafafa` / `#1f2328` text) to `.flowchart-canvas-host` and `.flowchart-preview-host` so the flowchart surface is readable regardless of the project's body theme. Forced explicit fills and strokes on `.flowchart-node rect` / `.flowchart-node polygon` / `.flowchart-node text` / `.flowchart-edge` (white fill, dark stroke, dark text). Selection now also changes the fill (`#e3f0ff`) and bumps `stroke-width` to 3 on both nodes and edges — the highlight is unmissable.
- `src/renderer.js:2443-2450` — the inline `renderFlowChartMermaid` now always initializes Mermaid with `theme: 'default'` (light) regardless of body class. Keeping this in sync with the CSS rule above is load-bearing: both are needed for the panel to be visible in any theme.
- **Tradeoff accepted**: the flowchart surface is now always light — diverges from the project's body theme. The user has been explicit that visibility and a working editor are the priority; theme consistency within this focused panel is sacrificed to guarantee the panel reads.
### Tests
- `tests/flowchart-panel.test.js` — new `describe('flowchart-panel: render target sizing (v4.9.5 regression)')` block (3 tests) reading the shipped CSS to assert: (a) `.flowchart-preview-render` has a non-zero `min-height`, (b) `.flowchart-canvas-host` / `.flowchart-preview-host` carry a forced background declaration with `!important`, (c) `.flowchart-node.selected rect/polygon` carry an explicit fill and `stroke-width >= 3`. Reading the stylesheet directly mirrors what the runtime loads via `<link rel="stylesheet">` and sidesteps jsdom's incomplete layout engine.
## Version 4.9.4 (2026-09-14)
### Fixes
- **Flowchart Panel — selection was invisible**: clicking a node or edge updated the canvas's internal `selectedNodeId` / `selectedEdgeId` but never repainted, so the `.flowchart-node.selected` / `.flowchart-edge.selected` CSS highlight only appeared when the user actually dragged (which triggers `store.subscribe` → `render()`). A bare click left the canvas looking unchanged, and the panel's own `selectedNodeId` (used by the panel-scoped Delete/Backspace shortcut) stayed `null`, so Delete on a freshly-clicked node silently no-op'd. Wired `opts.onNodeClick(id)` end-to-end:
- `src/flowchart/flowchart-canvas.js` — added an `opts.onNodeClick` callback parallel to the existing `opts.onEdgeClick`; on click, both branches now call a new surgical `applySelectionHighlight()` that toggles the `.selected` class on the existing `<g data-node-id>` / `<line data-edge-id>` elements without going through `render()` (which would detach the very element the user's pointer is still on, breaking `pointermove`/`pointerup` bubbling during a drag).
- `src/sidebar/flowchart-panel.js` — the panel's `onNodeClick` handler mirrors the id into the panel's `selectedNodeId` (clearing `selectedEdgeId`) so Delete/Backspace routes correctly. Same symmetry was already in place for `onEdgeClick`.
- **Flowchart Panel — narrow sidebar cramped the canvas + preview**: the panel lives in the 280 px sidebar, which split the canvas vs. preview to ~175 px each — too tight to edit a flowchart. Added a "Maximize / Restore" button to the panel toolbar (between the status text and the right edge). Clicking it toggles a `flowchart-takeover` class on `.main-content`:
- `src/styles-sidebar.css` — new `.main-content.flowchart-takeover` rules hide `.editor-container` (`display: none`) and let `.sidebar` / `.sidebar-panel` grow with `flex: 1` so the canvas + preview split the full window width instead of the 280 px sidebar.
- The button label flips between "Maximize" and "Restore", `aria-label` and `title` update, and the button gets an `.active` highlight while takeover is on. `destroy()` clears the class so leaving the panel doesn't leave the editor hidden for the rest of the session.
- The class lookup walks up from the panel container to the nearest `.main-content` ancestor (with a `document.querySelector('.main-content')` fallback) so the panel doesn't need to know whether the sidebar lives inside `#sidebar` or any future container.
### Tests
- `tests/flowchart-panel.test.js` — added two new `describe` blocks (8 tests total):
- "selection wiring (canvas click → panel state + SVG class)": clicking a node applies `.selected` to the matching `<g>`, clicking a second node moves `.selected` from the first to the second, clicking an edge applies `.selected` to the matching `<line>`, and a regression test verifying Delete removes a freshly-clicked node (was broken in v4.9.3 because the panel's `selectedNodeId` was never updated by canvas clicks).
- "maximize / takeover": the maximize button is exposed in the toolbar, clicking it toggles `.flowchart-takeover` on `.main-content` and flips the button label/active class, and `destroy()` clears the class so the editor stays usable.
- New `mountWithMainContent()` helper wraps the panel container in a fake `.main-content` (mirroring the real DOM layout in `src/index.html:2341`) so the takeover's class-toggling is observable from the test.
## Version 4.9.3 (2026-09-14)
### Fixes
- **Flowchart Panel — preview pane accumulated raw Mermaid source**: when the user fired several `addNode` mutations within the 250 ms preview debounce, `mermaid.run({ nodes: [div] })` is async, so the previous render's `<div class="mermaid">` (still carrying the source text) was sitting in `.flowchart-preview-render` when the next render cleared the target. The first render's eventual `element.innerHTML = svg` landed on a detached node, but the visible preview pane had a stack of stale `<div class="mermaid">` elements. Fixed in `src/renderer.js:2423-2449`: switched the inline `renderFlowChartMermaid` to `replaceChildren()` (more idiomatic than `innerHTML = ''`) and added a per-target `WeakSet` in-flight tracker that keeps the previous render's closure from racing the new render — its eventual `element.innerHTML = svg` is harmless on a detached node, and the new render always starts from a clean slate.
### Tests
- `tests/flowchart-panel.test.js` — added a second regression test (`preview-source pre never duplicates across debounced mutations even with in-flight mermaid.render`) that fires 7 mutations at 10 ms intervals (well inside the 250 ms debounce), uses a `renderMermaid` mock that mirrors the real mermaid.run closure (captures the input div, asynchronously sets `innerHTML = svg` on it regardless of DOM connection), and asserts the `<pre>` contains exactly one copy of the latest source and the render target holds exactly one `<div class="mermaid">` child whose first element child is the latest `<svg>`.
## Version 4.9.2 (2026-09-14)
### Fixes
- **Standalone ASCII Art Generator — broken stylesheet path**: `src/ascii-generator.html:7` linked `<link rel="stylesheet" href="../fonts.css" />`. The HTML loads via `BrowserWindow.loadFile(path.join(__dirname, 'ascii-generator.html'))` where `__dirname` is `src/`, so `../fonts.css` escaped the `src/` directory and resolved to a non-existent `<project>/fonts.css`. Changed to `fonts.css` (same file, src/-relative — mirrors `src/index.html:29`). The window rendered without its font rules, leaving the header in the fallback system stack.
- **Standalone ASCII Art Generator — dead Box/Templates UI**: the controller (`src/renderer/ascii-controller.js`) shipped three mode tabs (`Text Banner` / `Box-Frame` / `Templates`), 18 `.template-btn[data-template]` buttons, and a Box form (`#box-text` / `#box-style` / `#box-padding`) but wired none of them. Clicking any tab or button was a no-op. Wired all of them:
- `setMode(mode)` toggles `.active` on the right `.mode-tab` and the matching `.mode-section` (`text-mode` / `box-mode` / `templates-mode`).
- Template buttons call `api.generate({ text: '', font: 'template:<id>' })` so the orchestrator owns template content; preview updates and the button gets `.active`.
- Box mode renders the user text with a border using the chosen style (`single` / `double` / `rounded` / `bold` / `ascii`) and padding, exposed as a pure `window.ASCIIBoxRenderer.renderBox(text, style, padding)` helper.
- All 11 brief-required behaviours (text-input / font-picker / font-search / insert / copy / save / generate, last-font persistence, debounced preview) remain intact.
### Tests
- New `tests/ascii-controller.test.js` — 6 tests covering the stylesheet path (no `..` escape), pure box renderer (single + ascii styles), mode-tab switching (Box and Templates), and template button → preview wiring.
## Version 4.9.1 (2026-09-14)
### Fixes
- **Flowchart Panel save failed**: `getUserDataPath()` IPC was not awaited in `src/renderer.js:2439-2449`, so the persistence path was computed as `"[object Promise]/flowchart-session.json"` and rejected by the `write-text-file` userData sandbox. Pre-resolved the path on panel register and cached it; persistence (read and write) now works correctly.
## Version 4.9.0 (2026-09-14)
### New: Visual Flow Chart Editor (Sidebar panel → "Flow Chart")
- Pure `flowchart-store.js` — graph data store with bounded undo/redo (depth 50)
and injectable persistence IO (testable without touching the filesystem)
- 5 node shapes (process, decision, terminator, subroutine, document) × 3 edge
kinds (solid, dotted, thick) with full keyboard accessibility
- Hand-rolled SVG canvas: drag nodes, double-click to edit labels, Alt+drag from
a node edge to wire connections, in-place label editing, delete + backspace
to remove the selection
- `flowchart-shapes.js` — pure SVG path templates (no DOM, fully unit-tested)
- `flowchart-mermaid.js` — translates the graph to Mermaid `flowchart TD` source
that renders identically in the preview pane
- Sidebar panel `src/sidebar/flowchart-panel.js` with debounced preview (250 ms),
debounced persistence (500 ms), and Ctrl+Z / Ctrl+Shift+Z / Delete / Backspace
shortcuts
- 3 thin IPC channels (`get-user-data-path`, `read-text-file`, `write-text-file`)
sandboxed to `app.getPath('userData')` via path validation
- Auto-save to `<userData>/flowchart-session.json`; restores on reopen
- Rail button in the sidebar; toggle the panel with `Ctrl+Alt+F`
- Insert-at-Cursor wraps the generated Mermaid in a fenced ` ```mermaid ` block
at the current cursor position in the active editor tab
- 73 new tests
### New: ASCII Art Generator upgrade
- Pure `AsciiArt` orchestrator (`generate / listFonts / getFontMeta`) unifying
hand-coded fonts + figlet + 19 named templates behind one API
- 17 hand-coded font tables extracted from inline renderer code (5 existing +
12 new: Big, Small, Lean, Slant, Isometric1-4, 3-D, 3x5, ANSI Shadow, Calvin S)
- 19 named ASCII templates (arrows, flowcharts, banners, frames)
- `figlet@^1.8.0` dep with lazy-load + cache adapter (≥328 bundled fonts; pure
JS, no native bindings)
- 6 IPC handlers: `ascii:generate / list-fonts / get-font-meta / save / copy /
last-font`
- Standalone window rewrite: removed the ~385-line inline script; added a
searchable font picker, Copy to Clipboard, and Save to File
- Major cleanup: -1029 net lines of dead code (the in-app modal
`#ascii-art-dialog`, the 800-line renderer controller, the
`show-ascii-generator*` preload channels, and the obsolete
`textToASCII`/`createASCIIBox`/etc. helpers)
- 97 new tests
### New: Editor Theme Registry (extension)
- 12 new themes added: Catppuccin Latte / Frappé / Macchiato / Mocha, One Light,
Tokyo Night Storm, Synthwave '84, Outrun, Winter is Coming (Light + Dark),
Solarized Dark High Contrast, Spring Light
- Total: 37 themes (15 light + 22 dark incl. 1 high-contrast), sorted into
Light / Dark / High-Contrast tables in the README
- Pure `ThemeRegistry` module; the View menu generator now reads
`list() + categories()`; new per-theme CSS files live at
`src/styles/themes/<id>.css`
- `<link disabled>` preload + `<link>` toggle pattern (sub-millisecond theme
switch — no flash, no re-fetch)
- Aria-friendly: the high-contrast option is announced to assistive tech
### Bug fixes
- Plan 3: rail button tooltip `Ctrl+Alt+F` now actually wired (the shortcut
existed but the panel toggle was missing)
- Plan 3: `destroy()` cleanup on panel unmount — timers, listeners, and the
store subscription are all released (no leaks when toggling repeatedly)
- Plan 2: standalone window `<script src>` path corrected (was escaping `src/`)
- Plan 2: font substitutions reverted — `Isometric1-4` and `Calvin S` are
actually restored from figlet's bundled fonts
### Housekeeping
- 1093 tests passing across 36 snapshots in 90 suites
- Lint + Prettier clean across all 3 plans
- Linux build verified end-to-end (AppImage + deb + snap produced)
---
## Version 4.7.1 (2026-09-05) ## Version 4.7.1 (2026-09-05)
### New: Export Themes (Word + PDF) ### New: Export Themes (Word + PDF)
- Theme picker in the export dialog (basic and advanced mode) for PDF and DOCX: - Theme picker in the export dialog (basic and advanced mode) for PDF and DOCX:
**Default (Pandoc), Modern, Classic, Sepia, Minimal, Elegant** **Default (Pandoc), Modern, Classic, Sepia, Minimal, Elegant**
- PDF: LaTeX header recolors/reformats headings + links (xcolor/titlesec, - PDF: LaTeX header recolors/reformats headings + links (xcolor/titlesec,
@@ -12,10 +255,12 @@
Default instead of failing the export Default instead of failing the export
### Branding ### Branding
- New M↓ brand identity: app icons, favicons, tray icon, welcome mark, - New M↓ brand identity: app icons, favicons, tray icon, welcome mark,
README wordmark (vector kit in assets/markdown-converter-assets/) README wordmark (vector kit in assets/markdown-converter-assets/)
### Fixes ### Fixes
- **Windows**: pdfjs text/image extraction failed on Windows — - **Windows**: pdfjs text/image extraction failed on Windows —
standardFontDataUrl is now a proper file:// URL (raw backslash paths standardFontDataUrl is now a proper file:// URL (raw backslash paths
failed pdfjs's trailing-slash URL validation) failed pdfjs's trailing-slash URL validation)
@@ -33,6 +278,7 @@
## Version 4.7.0 (2026-09-05) ## Version 4.7.0 (2026-09-05)
### Bundling & Legal Compliance ### Bundling & Legal Compliance
- **MarkItDown is now bundled**: `npm run bundle:markitdown` freezes Microsoft's - **MarkItDown is now bundled**: `npm run bundle:markitdown` freezes Microsoft's
markitdown (MIT) + embedded Python runtime into a single ~75MB per-platform markitdown (MIT) + embedded Python runtime into a single ~75MB per-platform
binary (`bin/<platform>/markitdown`) via PyInstaller (ML extras excluded); binary (`bin/<platform>/markitdown`) via PyInstaller (ML extras excluded);
@@ -56,6 +302,7 @@
## Version 4.6.1 (2026-09-05) ## Version 4.6.1 (2026-09-05)
### New Features ### New Features
- **MarkItDown import** — "File → Import with MarkItDown (Any Format)…" embeds - **MarkItDown import** — "File → Import with MarkItDown (Any Format)…" embeds
Microsoft's [markitdown](https://github.com/microsoft/markitdown) (MIT) as an Microsoft's [markitdown](https://github.com/microsoft/markitdown) (MIT) as an
any-file → Markdown path: PDF, DOCX, PPTX, XLSX, Outlook .msg/.eml, EPUB, any-file → Markdown path: PDF, DOCX, PPTX, XLSX, Outlook .msg/.eml, EPUB,
@@ -74,6 +321,7 @@
without a trailing `/v1` without a trailing `/v1`
### Bug Fixes ### Bug Fixes
- File → Open PDF crashed the PDF editor (null operation matched no section; now - File → Open PDF crashed the PDF editor (null operation matched no section; now
defaults to Merge) defaults to Merge)
- Backlinks panel required the wrong module path (failed at registration) - Backlinks panel required the wrong module path (failed at registration)
@@ -89,6 +337,7 @@
### New Features ### New Features
#### AI Assistant Plugin (multi-provider) #### AI Assistant Plugin (multi-provider)
- Chat sidebar panel with rolling conversation history and insert-reply-into-document - Chat sidebar panel with rolling conversation history and insert-reply-into-document
- Providers: OpenAI, Anthropic, Ollama, LM Studio, and any OpenAI-compatible endpoint - Providers: OpenAI, Anthropic, Ollama, LM Studio, and any OpenAI-compatible endpoint
- All provider traffic proxied through the main process — API keys never enter the renderer and the CSP stays closed to AI endpoints - All provider traffic proxied through the main process — API keys never enter the renderer and the CSP stays closed to AI endpoints
@@ -96,30 +345,36 @@
- Answers the writing-studio `ai:analyze` contract, finally enabling the Proofread panel - Answers the writing-studio `ai:analyze` contract, finally enabling the Proofread panel
#### Collaboration Plugin (inline comments) #### Collaboration Plugin (inline comments)
- Anchor-based comments stored in `.comments/` sidecar files (never exported, never committed) - Anchor-based comments stored in `.comments/` sidecar files (never exported, never committed)
- Comments sidebar panel: add at cursor, list, resolve, delete, jump to anchor - Comments sidebar panel: add at cursor, list, resolve, delete, jump to anchor
- Drift detection flags moved/edited anchors; F8 navigates to the next open comment - Drift detection flags moved/edited anchors; F8 navigates to the next open comment
#### Local Knowledge Base (wiki-links + backlinks) #### Local Knowledge Base (wiki-links + backlinks)
- `[[Note]]`, `[[Note|alias]]`, `[[Note#section]]` render as links in the preview (code blocks excluded) - `[[Note]]`, `[[Note|alias]]`, `[[Note#section]]` render as links in the preview (code blocks excluded)
- Clicking a wiki-link opens the note or offers to create it - Clicking a wiki-link opens the note or offers to create it
- Backlinks sidebar panel scans the folder (bounded BFS) for documents linking to the current one - Backlinks sidebar panel scans the folder (bounded BFS) for documents linking to the current one
#### Crash Recovery / Session Restore #### Crash Recovery / Session Restore
- Open tabs (paths + unsaved buffer content) snapshotted to localStorage, debounced on edits, on tab changes, on unload, and once a minute - Open tabs (paths + unsaved buffer content) snapshotted to localStorage, debounced on edits, on tab changes, on unload, and once a minute
- Restore prompt on launch with per-tab restore, clean-fresh option, and 2MB content budget - Restore prompt on launch with per-tab restore, clean-fresh option, and 2MB content budget
#### Document Version History #### Document Version History
- Every save snapshots the previous on-disk content to `<userData>/versions/` - Every save snapshots the previous on-disk content to `<userData>/versions/`
- History sidebar panel: list, restore (with safety snapshot), unified diff vs. current, delete, manual "save version now" - History sidebar panel: list, restore (with safety snapshot), unified diff vs. current, delete, manual "save version now"
- Per-document pruning (20 versions), path-hash storage, id-validated reads - Per-document pruning (20 versions), path-hash storage, id-validated reads
#### Editor #### Editor
- Vim keybindings (View → Vim Mode, persisted, live toggle via CodeMirror Compartment) - Vim keybindings (View → Vim Mode, persisted, live toggle via CodeMirror Compartment)
- Snippet Tab-expansion: type a snippet name and press Tab to insert it - Snippet Tab-expansion: type a snippet name and press Tab to insert it
- Zen Mode word-goal setter (the HUD progress bar finally has UI) - Zen Mode word-goal setter (the HUD progress bar finally has UI)
#### Export / Conversion #### Export / Conversion
- **Real PDF encryption**: pdf-lib swapped for @cantoo/pdf-lib — encrypt/decrypt/permissions now actually work (UI auto-enables via the capability probe) - **Real PDF encryption**: pdf-lib swapped for @cantoo/pdf-lib — encrypt/decrypt/permissions now actually work (UI auto-enables via the capability probe)
- **XLSX export**: markdown tables → native Excel workbook, one sheet per table (no Pandoc needed) - **XLSX export**: markdown tables → native Excel workbook, one sheet per table (no Pandoc needed)
- **ODT headers/footers + page size**: real ODF styles.xml patching replaces the empty stub - **ODT headers/footers + page size**: real ODF styles.xml patching replaces the empty stub
@@ -128,6 +383,7 @@
- Writing heatmap (GitHub-style 30-day grid) in the writing-studio Goals panel - Writing heatmap (GitHub-style 30-day grid) in the writing-studio Goals panel
#### Platform #### Platform
- Quick Note global scratchpad (Ctrl+Alt+Q, works when unfocused; appends to `notes/quick-notes.md`) - Quick Note global scratchpad (Ctrl+Alt+Q, works when unfocused; appends to `notes/quick-notes.md`)
- Deep link protocol `markdownconverter://open?path=…` - Deep link protocol `markdownconverter://open?path=…`
- REPL confirmation dialog before first code execution per language per session (unsandboxed-execution guard rail) - REPL confirmation dialog before first code execution per language per session (unsandboxed-execution guard rail)
@@ -135,6 +391,7 @@
- Plugin sidebar panels get automatic rail icons via `registerPanel({icon})` - Plugin sidebar panels get automatic rail icons via `registerPanel({icon})`
### Bug Fixes ### Bug Fixes
- `Ctrl+Shift+P` collision: PDF (Enhanced) export now `Ctrl+Alt+Shift+P`; Command Palette keeps `Ctrl+Shift+P` - `Ctrl+Shift+P` collision: PDF (Enhanced) export now `Ctrl+Alt+Shift+P`; Command Palette keeps `Ctrl+Shift+P`
- Universal Converter's Pandoc tool no longer always reports "not installed" (`checkConverterAvailable` gained a pandoc case with bundled-binary check) - Universal Converter's Pandoc tool no longer always reports "not installed" (`checkConverterAvailable` gained a pandoc case with bundled-binary check)
- CLI headless export: removed dangling `--css` / `--reference-doc` flags that made pandoc exit with an error; `--self-contained` replaced with `--standalone` (Pandoc 3.x) - CLI headless export: removed dangling `--css` / `--reference-doc` flags that made pandoc exit with an error; `--self-contained` replaced with `--standalone` (Pandoc 3.x)
@@ -142,11 +399,13 @@
- Removed duplicate `styles-zen.css` include - Removed duplicate `styles-zen.css` include
### Security ### Security
- AI provider requests carry size caps (200KB prompt), timeouts (120s), and user-safe error surfaces - AI provider requests carry size caps (200KB prompt), timeouts (120s), and user-safe error surfaces
- Version-history reads validate ids against traversal; history listing requires a valid document path - Version-history reads validate ids against traversal; history listing requires a valid document path
- PlantUML local rendering removes the diagram-text exfiltration path when a local CLI exists (CVE-MC-007 follow-up) - PlantUML local rendering removes the diagram-text exfiltration path when a local CLI exists (CVE-MC-007 follow-up)
### Tests ### Tests
- New suites: OdtStyling, AiProviders, ai-assistant prompts, collaboration comment-store, wiki-links/backlinks, session-store, XlsxExporter, VersionHistory - New suites: OdtStyling, AiProviders, ai-assistant prompts, collaboration comment-store, wiki-links/backlinks, session-store, XlsxExporter, VersionHistory
- PDFOperations encryption tests rewritten for the real-encryption reality - PDFOperations encryption tests rewritten for the real-encryption reality
@@ -155,12 +414,14 @@
## Version 4.0.0 (2026-03-04) ## Version 4.0.0 (2026-03-04)
### Major Changes ### Major Changes
- **CodeMirror 6 Editor** — Replaced textarea with CodeMirror 6 featuring syntax highlighting, code folding, bracket matching, multiple cursors, and auto-indent - **CodeMirror 6 Editor** — Replaced textarea with CodeMirror 6 featuring syntax highlighting, code folding, bracket matching, multiple cursors, and auto-indent
- **Sidebar Panel System** — Collapsible sidebar with File Explorer, Git, Snippets, and Templates panels - **Sidebar Panel System** — Collapsible sidebar with File Explorer, Git, Snippets, and Templates panels
- **Command Palette** — Ctrl+Shift+P to search and execute all app actions - **Command Palette** — Ctrl+Shift+P to search and execute all app actions
- **Code Execution (REPL)** — Run JavaScript, Python, and Bash code blocks directly from the preview - **Code Execution (REPL)** — Run JavaScript, Python, and Bash code blocks directly from the preview
### New Features ### New Features
- Print Preview dialog with paper size, orientation, margins, scale, and page range controls - Print Preview dialog with paper size, orientation, margins, scale, and page range controls
- Image paste from clipboard and drag-drop support with auto-save to assets folder - Image paste from clipboard and drag-drop support with auto-save to assets folder
- Document templates library (10 templates: blog post, meeting notes, tech spec, changelog, README, project plan, API docs, tutorial, release notes, comparison) - Document templates library (10 templates: blog post, meeting notes, tech spec, changelog, README, project plan, API docs, tutorial, release notes, comparison)
@@ -173,6 +434,7 @@
- Breadcrumb bar showing current file path - Breadcrumb bar showing current file path
### New Export/Import Formats ### New Export/Import Formats
- Reveal.js slides (.html) - Reveal.js slides (.html)
- Beamer slides (.pdf) - Beamer slides (.pdf)
- Confluence/Jira wiki markup (.txt) - Confluence/Jira wiki markup (.txt)
@@ -180,12 +442,14 @@
- Developer formats: JSON, YAML, XML, TOML - Developer formats: JSON, YAML, XML, TOML
### Security ### Security
- Content Security Policy (CSP) meta tag - Content Security Policy (CSP) meta tag
- File size validation (50MB limit) - File size validation (50MB limit)
- Error message sanitization (stripped file paths) - Error message sanitization (stripped file paths)
- Conversion rate limiting (2-second debounce) - Conversion rate limiting (2-second debounce)
### Dependencies Updated ### Dependencies Updated
- marked: 16.x to 17.x (with marked-highlight extension) - marked: 16.x to 17.x (with marked-highlight extension)
- pdfjs-dist: 3.x to 5.x (new worker model) - pdfjs-dist: 3.x to 5.x (new worker model)
- html2pdf.js: 0.10 to 0.14 - html2pdf.js: 0.10 to 0.14
@@ -193,10 +457,12 @@
- dompurify, docx, and others updated to latest - dompurify, docx, and others updated to latest
### Testing ### Testing
- 80 tests across 7 test suites - 80 tests across 7 test suites
- New tests for sidebar manager, command palette, print preview, markdown extensions, and utility functions - New tests for sidebar manager, command palette, print preview, markdown extensions, and utility functions
### Breaking Changes ### Breaking Changes
- Editor is now CodeMirror 6 (replaces textarea) - Editor is now CodeMirror 6 (replaces textarea)
- marked API changed to use marked.use() instead of marked.setOptions() - marked API changed to use marked.use() instead of marked.setOptions()
- pdfjs-dist upgraded to v5 with new worker model - pdfjs-dist upgraded to v5 with new worker model
@@ -208,6 +474,7 @@
### 🎨 UI/UX Improvements ### 🎨 UI/UX Improvements
#### Subtle & Small Preview Popout Button #### Subtle & Small Preview Popout Button
- Redesigned popout button with minimalist aesthetic - Redesigned popout button with minimalist aesthetic
- Removed border for cleaner appearance - Removed border for cleaner appearance
- Reduced size: 11px font, 2px×6px padding (previously 14px font, 4px×8px padding) - Reduced size: 11px font, 2px×6px padding (previously 14px font, 4px×8px padding)
@@ -216,6 +483,7 @@
- **File**: `src/styles.css:195-211` - **File**: `src/styles.css:195-211`
#### Simplified Table Headers in Preview #### Simplified Table Headers in Preview
- Removed gradient background from table headers in modern theme - Removed gradient background from table headers in modern theme
- Changed from `var(--primary-gradient)` (purple gradient) to simple light gray (#f0f0f0) - Changed from `var(--primary-gradient)` (purple gradient) to simple light gray (#f0f0f0)
- Updated text color to dark (#333333) for better readability - Updated text color to dark (#333333) for better readability
@@ -225,9 +493,11 @@
### 📥 Enhanced Import Capabilities ### 📥 Enhanced Import Capabilities
#### Comprehensive Format-to-Markdown Conversion #### Comprehensive Format-to-Markdown Conversion
Dramatically expanded the "Import Document" feature to support 30+ file formats: Dramatically expanded the "Import Document" feature to support 30+ file formats:
**Supported Formats:** **Supported Formats:**
- **Documents**: DOCX, ODT, RTF, HTML, HTM, TEX, EPUB, PDF, TXT - **Documents**: DOCX, ODT, RTF, HTML, HTM, TEX, EPUB, PDF, TXT
- **Presentations**: PPTX, ODP - **Presentations**: PPTX, ODP
- **Markup Languages**: RST, Textile, MediaWiki, Org-mode, AsciiDoc, TWiki, OPML - **Markup Languages**: RST, Textile, MediaWiki, Org-mode, AsciiDoc, TWiki, OPML
@@ -238,6 +508,7 @@ Dramatically expanded the "Import Document" feature to support 30+ file formats:
- **Data Formats**: CSV, TSV, JSON - **Data Formats**: CSV, TSV, JSON
**Format-Specific Optimizations:** **Format-Specific Optimizations:**
- PDF text extraction with XeLaTeX engine - PDF text extraction with XeLaTeX engine
- CSV/TSV automatic table conversion - CSV/TSV automatic table conversion
- JSON structure handling - JSON structure handling
@@ -249,6 +520,7 @@ Dramatically expanded the "Import Document" feature to support 30+ file formats:
### 🎨 Exhaustive ASCII Art Generator ### 🎨 Exhaustive ASCII Art Generator
#### 5 New Text Banner Styles #### 5 New Text Banner Styles
Complete alphabet (A-Z) and numbers (0-9) support for all styles: Complete alphabet (A-Z) and numbers (0-9) support for all styles:
1. **Standard** - Classic ASCII art with slashes and underscores 1. **Standard** - Classic ASCII art with slashes and underscores
@@ -260,15 +532,18 @@ Complete alphabet (A-Z) and numbers (0-9) support for all styles:
**File**: `src/renderer.js:3397-3537` **File**: `src/renderer.js:3397-3537`
#### 19 Professional ASCII Templates #### 19 Professional ASCII Templates
Organized into 4 categories with expanded options: Organized into 4 categories with expanded options:
**Arrows & Flow (4 templates):** **Arrows & Flow (4 templates):**
- Arrow Right - Horizontal flow indicators - Arrow Right - Horizontal flow indicators
- Arrow Down - Vertical flow indicators - Arrow Down - Vertical flow indicators
- Decision - Binary decision diagrams - Decision - Binary decision diagrams
- Process Flow - Multi-step process visualization - Process Flow - Multi-step process visualization
**Diagrams & Charts (6 templates):** **Diagrams & Charts (6 templates):**
- Flowchart - Advanced flowchart with decision branches and loops - Flowchart - Advanced flowchart with decision branches and loops
- Sequence - Sequence diagrams for User-System-Database interactions - Sequence - Sequence diagrams for User-System-Database interactions
- Network - Server-client network topology - Network - Server-client network topology
@@ -277,12 +552,14 @@ Organized into 4 categories with expanded options:
- Table Simple - Basic table template with borders - Table Simple - Basic table template with borders
**Boxes & Containers (4 templates):** **Boxes & Containers (4 templates):**
- Header - Section header with decorative borders - Header - Section header with decorative borders
- Note Box - Important notes with rounded corners (┏━━┓) - Note Box - Important notes with rounded corners (┏━━┓)
- Warning Box - Warning messages with bold borders (╔═══╗) - Warning Box - Warning messages with bold borders (╔═══╗)
- Info Box - Information boxes with subtle styling (╭───╮) - Info Box - Information boxes with subtle styling (╭───╮)
**Decorative Elements (6 templates):** **Decorative Elements (6 templates):**
- Divider - Horizontal section separator (═══) - Divider - Horizontal section separator (═══)
- Separator Fancy - Elegant rounded divider - Separator Fancy - Elegant rounded divider
- Brackets - Japanese-style brackets 【 】 - Brackets - Japanese-style brackets 【 】
@@ -291,6 +568,7 @@ Organized into 4 categories with expanded options:
- Progress Bar - Visual progress indicators - Progress Bar - Visual progress indicators
**Features:** **Features:**
- All ASCII art automatically wrapped in code blocks for proper rendering - All ASCII art automatically wrapped in code blocks for proper rendering
- Preserved formatting in markdown preview and all export formats - Preserved formatting in markdown preview and all export formats
- Categorized template selection interface - Categorized template selection interface
@@ -320,6 +598,7 @@ Organized into 4 categories with expanded options:
## Version 2.0.0 (Previous Release) ## Version 2.0.0 (Previous Release)
### Major Features ### Major Features
- Export Profiles - Save and reuse export configurations - Export Profiles - Save and reuse export configurations
- Mermaid.js diagram support - Mermaid.js diagram support
- Command Palette (Ctrl+Shift+P) - Command Palette (Ctrl+Shift+P)
@@ -334,6 +613,7 @@ Organized into 4 categories with expanded options:
- 22 beautiful themes - 22 beautiful themes
### Core Capabilities ### Core Capabilities
- Cross-platform markdown editor with live preview - Cross-platform markdown editor with live preview
- Universal document conversion (30+ formats) - Universal document conversion (30+ formats)
- PDF Editor (merge, split, compress, rotate, watermark, encrypt) - PDF Editor (merge, split, compress, rotate, watermark, encrypt)
@@ -347,13 +627,16 @@ Organized into 4 categories with expanded options:
## Installation & Usage ## Installation & Usage
### Prerequisites ### Prerequisites
- **Pandoc** - Required for document conversion - **Pandoc** - Required for document conversion
- **Optional**: LibreOffice, ImageMagick, FFmpeg for universal converter - **Optional**: LibreOffice, ImageMagick, FFmpeg for universal converter
### Download ### Download
Get the latest release from: https://github.com/amitwh/pan-converter/releases Get the latest release from: https://github.com/amitwh/pan-converter/releases
### Supported Platforms ### Supported Platforms
- Windows (x64) - Windows (x64)
- Linux (AppImage, .deb, .snap) - Linux (AppImage, .deb, .snap)
- macOS (planned) - macOS (planned)
@@ -27,7 +27,7 @@
| ID | Risk | Disposition | | ID | Risk | Disposition |
|----|------|-------------| |----|------|-------------|
| D1 | **Real PDF encryption unavailable** (pdf-lib limitation) — feature now fails honestly rather than lying | Accepted for this release. Restoring it means swapping pdf-lib for an encryption-capable fork (e.g. `@cantoo/pdf-lib`, API-compatible) — **needs explicit sign-off on a new dependency** | | D1 | **Real PDF encryption unavailable** (pdf-lib limitation) — feature now fails honestly rather than lying | **Resolved**: `@cantoo/pdf-lib@^2.9.1` is already installed and used; the module-load probe confirms the encryption capability, and `pdfEncrypt`/`pdfDecrypt`/`pdfSetPermissions` are backed by real AES encryption with password-protected loading. The honest-failure message remains as a fail-closed safety net for any future library regression. See `tests/main/PDFOperations.test.js` "real encryption" suite (35 tests passing). |
| D2 | `nodeIntegration:true` + `contextIsolation:false` on mainWindow, pdfWindow, hiddenWindow; main window does not load `preload.js` (inline shim instead) — the IPC whitelist is a live control only on the two generator windows | Accepted legacy risk for this branch; owned by the react-electron migration (contextIsolation + preload-everywhere), tracked separately | | D2 | `nodeIntegration:true` + `contextIsolation:false` on mainWindow, pdfWindow, hiddenWindow; main window does not load `preload.js` (inline shim instead) — the IPC whitelist is a live control only on the two generator windows | Accepted legacy risk for this branch; owned by the react-electron migration (contextIsolation + preload-everywhere), tracked separately |
| D3 | Generator-window preload whitelist is broad (`execute-code`, `read-file`, `write-file`, `delete-file` reachable from isolated windows) | No current content vector into those windows; flag for the migration to narrow per-window APIs | | D3 | Generator-window preload whitelist is broad (`execute-code`, `read-file`, `write-file`, `delete-file` reachable from isolated windows) | No current content vector into those windows; flag for the migration to narrow per-window APIs |
| D4 | CSP allows `'unsafe-inline'` / `'unsafe-eval'` (required by marked + Mermaid rendering model) | Accepted; revisit under the migration with a nonce-based CSP | | D4 | CSP allows `'unsafe-inline'` / `'unsafe-eval'` (required by marked + Mermaid rendering model) | Accepted; revisit under the migration with a nonce-based CSP |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
# SP-1: Quick-switcher fuzzy overlay + Inline AI assist
**Branch:** master
**Started:** 2026-09-30 IST
**Status:** in progress
## Scope
Replace the existing Cmd+P "Recent Files" menu with a fuzzy quick-switcher
overlay, and add Cmd+K inline AI assist (Rewrite / Shorten / Expand) on
selected text using the existing `AiProviders` infrastructure.
## Design decisions (user-approved)
| Question | Answer |
|---|---|
| Cmd+P behavior | Replace Recent Files menu with overlay |
| AI response mode | Typewriter stream into selection |
| AI action set | Rewrite + Shorten + Expand only (no custom prompts) |
## Commit plan
### SP-1A — Quick-switcher fuzzy overlay
- [ ] **C1**: `feat(quick-switcher): pure fuzzy matcher + ranker` — `src/quick-switcher/fuzzy-matcher.js` + tests
- [ ] **C2**: `feat(quick-switcher): IPC for listing workspace files` — main.js + preload.js wiring
- [ ] **C3**: `feat(quick-switcher): overlay UI + keyboard nav` — `src/quick-switcher/quick-switcher-overlay.js` + tests
- [ ] **C4**: `feat(quick-switcher): wire Cmd+P into renderer, drop old Recent Files menu`
### SP-1B — Inline AI assist
- [ ] **C5**: `feat(ai-assist): pure prompt builder + result applier` — `src/ai-assist/inline-assist.js` + tests
- [ ] **C6**: `feat(ai-assist): IPC streaming wrapper around AiProviders` — main.js handler
- [ ] **C7**: `feat(ai-assist): floating popover + cancel/stop controls` — `src/renderer/inline-ai-popover.js` + tests
- [ ] **C8**: `feat(ai-assist): Cmd+K keymap + popover mount in renderer`
## TDD discipline
- All pure modules test: integration tests first, then implementation
- All renderer UI test: DOM contract tests using @testing-library/dom
- All IPC handlers: tests using injected fetch stub
- Lint + format green at every commit
- Grep forbidden markers before each commit claim
## Out of scope (deferred)
- Per-action configurable prompts
- Multi-selection batch assist
- AI inline assist without specific settings (built on AI Assistant plugin config)
@@ -0,0 +1,183 @@
# MarkdownConverter — ASCII Art Generator Upgrade Design
**Date:** 2026-09-14
**Status:** Draft — pending review
**Author:** Amit Haridas
## Overview
Promote the ASCII Art Generator from "works but untested, dual-implemented, 5 fonts" to a full-fledged feature with 17 hand-coded fonts + 400+ FIGlet fonts, single source of truth, comprehensive test coverage, and three output paths (insert at cursor, copy to clipboard, save to file). Consolidate the dead-code in-app modal in favor of the existing standalone `BrowserWindow`.
## Goals
1. **Single implementation path.** Delete the dead `#ascii-art-dialog` modal markup, its `ModalManager` instance, the `showASCIIGenerator*` dead channels in `preload.js`, and the corresponding renderer controller at `renderer.js:5942-6727`. The standalone `ascii-generator.html` window remains the only path.
2. **Many more fonts.** Add 12 hand-coded fonts (Big, Small, Lean, Slant, Isometric1-4, 3-D, 3x5, ANSI Shadow, Calvin S) on top of the 5 existing (standard, banner, block, bubble, digital). Plus all `figlet` npm fonts (400+) accessible through a searchable picker.
3. **Three output destinations.** Insert at cursor (existing — wraps result in a fenced code block), Copy to clipboard (new), Save to `.txt` file (new).
4. **Comprehensive test coverage.** The current `textToASCII`, `createASCIIBox`, `getASCIITemplate`, and the new `figlet` adapter all have ZERO tests today. Add full coverage — snapshot tests for known outputs across all fonts, integration tests for copy/save/insert.
5. **Pure-module architecture.** Move all algorithm code from `renderer.js` into `src/main/AsciiArt.js` (pure module, IPC-coupled via `main.js`, mirroring the `DocQA`/`DailyNotes` pattern).
## Non-Goals (v1)
- No animated ASCII / motion ASCII.
- No color / ANSI escape codes (would break the markdown fenced code block contract).
- No font upload — only shipped fonts (hand-coded + `figlet` standard library).
- No image export (PNG/SVG of ASCII art) — text only.
- No undo history inside the modal — single-shot generation.
- No in-modal text editing of the generated ASCII (the user can paste into the editor instead).
## Decisions Locked
| Decision | Choice | Reason |
|---|---|---|
| Library | `figlet` npm package (latest, ~400 KB unpacked) | Industry-standard JS port of the original C library; 400+ fonts; small footprint |
| Hand-coded fonts | Add 12 more, keep existing 5 as `legacy/` set | Hand-coded fonts have a distinct aesthetic (`figlet` doesn't 1:1 replicate all of them) and load with zero I/O |
| Font loading | Hand-coded: synchronous. `figlet`: lazy-loaded on font-picker open (async) | Avoids 400 KB of synchronous font loading at app startup |
| Module split | `src/main/AsciiArt.js` (pure), `src/renderer/ascii-controller.js` (UI), `src/ascii-generator.html` (already exists) | Matches established renderer-controller + main-module split |
| Single source of truth | Standalone window only; in-app modal deleted | Dead code is technical debt; consolidation is required for testability |
| Output destinations | Insert at cursor (existing) + Copy clipboard + Save to file | Covers all "I want this ASCII art in my markdown" use cases |
| Insert wrapper | Result wrapped in `\n\`\`\`\n…\n\`\`\`\n` (existing behaviour) | Preserves current markdown-fence contract |
| Persistence | None — the modal is stateless. Last-used font remembered via `electron-store` `ascii:lastFont` key (new). | Small UX win without adding persistence layer |
| Tests | jsdom + Jest, snapshot tests for known font outputs | Same pattern as existing `DocQA.test.js` |
## Architecture
```
┌─────────────────────────┐
Renderer (UI): │ src/renderer/ │
- ascii-generator │ ascii-controller.js │
.html │ - font-picker │
- ascii- │ - input/options │
controller.js │ - preview │
│ - 3 action buttons │
└────────┬────────────────┘
│ IPC: ascii:generate, ascii:list-fonts,
│ ascii:last-font, ascii:save
▼
┌─────────────────────────┐
Main (pure): │ src/main/AsciiArt.js │
│ generate(text, font, │
│ options) │
│ listFonts() │
│ getFontMeta(id) │
└────────┬────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Hand-coded │ │ figlet │ │ Templates │
│ (sync) │ │ (lazy async) │ │ (19 existing)│
│ 17 fonts │ │ 400+ fonts │ │ │
└─────────────┘ └──────────────┘ └──────────────┘
```
## New Modules
| File | Role |
|---|---|
| `src/main/AsciiArt.js` | Pure module. `generate({ text, font, options })` returns the ASCII string. `listFonts()` returns `{ id, label, kind: 'hand-coded'\|'figlet'\|'template', sample }[]`. `getFontMeta(id)` returns `{ kind, height, supportedChars }`. Lazy-loads `figlet` on first `figlet:*` font request; caches font list. |
| `src/main/AsciiArt.fonts.js` | Hand-coded font tables (5 existing + 12 new) extracted from `renderer.js:6005-6474`. Exports `HAND_CODED_FONTS` as `{ id → { height, chars: { 'A' → string[height], … } } }`. |
| `src/renderer/ascii-controller.js` | Renderer-side controller. Wires input/options/preview/action-buttons in the standalone window. Calls `window.api.ascii.generate(...)` etc. (new preload bindings). |
| `tests/ascii-art.test.js` | Unit tests for `AsciiArt.generate()` across hand-coded fonts (snapshot), `listFonts()` shape, `getFontMeta()`, figlet adapter (mocked), template adapter. |
| `tests/ascii-art.fonts.test.js` | Per-hand-coded-font known-output snapshot tests for the word "HELLO" (and a few edge cases: empty string, single char, mixed case, digits). |
| `tests/ascii-art.templates.test.js` | Per-template known-output snapshot for `getASCIITemplate('arrow-right', {})` etc. |
| `tests/preload-ascii.test.js` | Asserts the new `ascii:*` IPC channels are declared in `preload.js` allow-list. |
## Modified Modules
| File | Change |
|---|---|
| `src/ascii-generator.html` | Add: (a) searchable font picker dropdown (debounced 100 ms input filter over `listFonts()`), (b) two new buttons: "Copy to Clipboard" + "Save to File", (c) call `window.api.ascii.generate(...)` instead of running `textToASCII` inline. Layout otherwise unchanged. |
| `src/main.js:5584-5611` (`openAsciiGenerator`) | No structural change. The IPC channel name stays `open-ascii-generator`. |
| `src/main.js` (new IPC handlers, after line 5611) | Register: `ipcMain.handle('ascii:generate', …)`, `ipcMain.handle('ascii:list-fonts', …)`, `ipcMain.handle('ascii:get-font-meta', …)`, `ipcMain.handle('ascii:save', …)` (writes user-selected path), `ipcMain.handle('ascii:last-font', …)` (read + write via `electron-store`). |
| `src/main.js` (require block) | Add `const AsciiArt = require('./main/AsciiArt')` near `main.js:4005-4010`. |
| `src/preload.js:98-99,294-296` | **Delete** the dead `show-ascii-generator` and `show-ascii-generator-window` channels. Add new `ascii:generate`, `ascii:list-fonts`, `ascii:get-font-meta`, `ascii:save`, `ascii:last-font` to `validInvokeChannels`. Add the `generators.ascii` namespace helpers. |
| `src/renderer.js:2188` | **Delete** the `new ModalManager('#ascii-art-dialog')` instantiation. |
| `src/renderer.js:5942-6727` | **Delete** the entire in-app ASCII modal block: `showASCIIGenerator`, `hideASCIIGenerator`, `generateASCIIPreview`, `insertASCIIArt`, `createASCIIBox`, `getASCIITemplate`, `textToASCII`, all 5 font tables, the 19 template strings. (Total ~786 lines.) |
| `src/index.html:814-…` (`#ascii-art-dialog` markup) | **Delete** the entire modal markup block. |
| `src/styles.css` + other stylesheets | Remove any CSS tied only to the deleted modal. |
| `package.json` dependencies | Add `"figlet": "^1.8.0"` (or current latest). |
| `README.md:55-57` (ASCII Art Generator row) | Update to mention "17 hand-coded fonts + 400+ FIGlet fonts, copy/save/insert". |
| `README.md:124` (shortcut) | No change — `Ctrl+Shift+A` keeps working (existing standalone window path). |
| `electron-builder.config.js` | No change (pure-JS dep). |
## Hand-Coded Fonts (12 new)
Each is a `{ height, chars: { 'A': [...], 'B': [...], ... '0'..'9', ' ' } }` table. Heights vary 4–8 rows.
| id | label | height | source inspiration |
|---|---|---|---|
| `big` | Big | 8 | `figlet` "Big" |
| `small` | Small | 5 | `figlet` "Small" |
| `lean` | Lean | 6 | `figlet` "Lean" |
| `slant` | Slant | 6 | `figlet` "Slant" (skewed) |
| `isometric1` | Isometric 1 | 6 | `figlet` "Isometric1" |
| `isometric2` | Isometric 2 | 6 | `figlet` "Isometric2" |
| `isometric3` | Isometric 3 | 6 | `figlet` "Isometric3" |
| `isometric4` | Isometric 4 | 6 | `figlet` "Isometric4" |
| `three-d` | 3-D | 7 | `figlet` "3-D" |
| `three-x-five` | 3x5 | 5 | `figlet` "3x5" |
| `ansi-shadow` | ANSI Shadow | 8 | `figlet` "ANSI Shadow" |
| `calvin-s` | Calvin S | 7 | `figlet` "Calvin S" |
Plus the 5 existing (standard, banner, block, bubble, digital). Total 17 hand-coded.
## New UI Features
1. **Searchable font picker.** Dropdown with text input. Lists all `listFonts()` results (17 hand-coded + 400+ figlet = ~417). Fuzzy-match on label, debounced 100 ms. Selecting a font renders a 5-char preview (`"HELLO"`) inline.
2. **Copy to Clipboard button.** Copies the rendered ASCII art to the OS clipboard. Uses Electron's `clipboard.writeText()` (already available via `require('electron').clipboard` in main process — exposed as `window.api.ascii.copy(text)` via new preload helper).
3. **Save to File button.** Opens a `dialog.showSaveDialog` with default `.txt` extension, writes the rendered ASCII art. Returns the saved path on success.
4. **Last-used font memory.** New `electron-store` key `ascii:lastFont`. On window open, preselect the last-used font.
## Data Flow
1. User triggers `Ctrl+Shift+A` (existing) or Tools → ASCII Art Generator menu (existing).
2. `main.js:5584` `openAsciiGenerator()` creates/ focuses the standalone `BrowserWindow` (existing behaviour).
3. Renderer loads `src/ascii-generator.html`. The page calls `window.api.ascii.listFonts()` on mount, populates the font picker.
4. User types text, selects font, chooses options (box style, padding) → the preview pane debounces (200 ms) and calls `window.api.ascii.generate(...)`.
5. Click "Insert": renderer emits existing `insert-generated-content` IPC → main forwards to `mainWindow.webContents.send('insert-content', …)` → editor's `tabManager.insertAtCursor` runs, wrapped in fenced code block.
6. Click "Copy": renderer calls `window.api.ascii.copy(text)` → main process writes to clipboard via `clipboard.writeText`.
7. Click "Save": renderer calls `window.api.ascii.save(text)` → main shows save dialog → writes file → returns path. UI shows success toast.
## Error Handling
| Scenario | Handling |
|---|---|
| `figlet` fails to lazy-load (dep missing, native binding error) | `AsciiArt.listFonts()` returns hand-coded fonts only; logs a warning. Picker shows "FIGlet fonts unavailable" notice. |
| `figlet` font file missing (corrupt install) | `AsciiArt.generate({ font: 'figlet:Big' })` throws a structured error → controller shows toast + falls back to last working font. |
| User picks a font with unsupported characters (e.g. non-ASCII) | `generate()` substitutes `?` per character; controller shows a one-line warning above the preview. |
| Clipboard write fails (rare, OS lock) | `clipboard.writeText` returns synchronously and almost never throws; if it does, the controller shows a toast and falls back to "Save to File". |
| Save dialog cancelled | Returns `{ canceled: true }`; UI shows no error. |
| IPC channel not allowed | `preload.js` is the gate; renderer can only call declared channels. Lint catches new channel declarations missing from allow-list. |
## Testing
1. **Unit (`tests/ascii-art.test.js`)** — `generate({ text: 'HELLO', font: 'big', options: {} })` returns the expected ASCII string (snapshot); `listFonts()` returns `{ id, label, kind, sample }[]` with hand-coded first; `getFontMeta('big')` returns `{ kind: 'hand-coded', height: 8 }`.
2. **Snapshot (`tests/ascii-art.fonts.test.js`)** — One snapshot per hand-coded font for `generate({ text: 'HELLO', font: '<id>' })`. Snapshots are checked into `tests/__snapshots__/ascii-art.fonts.test.js.snap`. Edge cases: empty string, single char, mixed case, digits.
3. **Snapshot (`tests/ascii-art.templates.test.js`)** — One snapshot per existing template (`arrow-right`, `flowchart`, etc.).
4. **Adapter (`tests/ascii-art.figlet-adapter.test.js`)** — `figlet.generate` is mocked; assert the adapter calls through with correct options, handles the promise, handles errors.
5. **Integration (`tests/ascii-art.electron.test.js`)** — Spawn the actual `ascii-generator.html` window via `electron` binary in headless mode and assert the window loads without console errors. Skip if no display available.
6. **Preload (`tests/preload-ascii.test.js`)** — Asserts all new `ascii:*` channels are in `validInvokeChannels` allow-list; old `show-ascii-generator*` channels are gone.
## Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Hand-coded font tables are tedious to author and error-prone | Medium | Snapshot tests catch drift; visual review for first 4; the rest follow the pattern. |
| `figlet` npm package has unexpected transitive deps or native bindings | Low | `figlet` is pure JS in v1.x; no native deps. Verify with `npm ls figlet` after install. |
| Adding `figlet` increases bundle size (~400 KB unpacked, ~150 KB gzipped in asar) | Low | Acceptable: ASCII art is a featured function. Document in `THIRD-PARTY-NOTICES.md` (MIT license). |
| Deleting 786 lines from `renderer.js` might break something unrelated | Low | The deleted block is self-contained. `git grep "showASCIIGenerator\|textToASCII\|createASCIIBox\|getASCIITemplate"` after deletion should return only the new module location. |
| Snapshot tests for fonts become noisy (whitespace, line endings) | Medium | Normalize line endings to `\n`; trim trailing whitespace; commit a stable snapshot per font after manual review. |
| Standalone window cannot share state with the editor (last-used font, theme sync) | Low | The standalone window is its own `BrowserWindow`; use `electron-store` (already used app-wide) for cross-window state. Theme sync is out of scope (standalone window has its own theme via `body.theme-<id>` from Spec 1). |
## Acceptance Criteria
- [ ] `src/main/AsciiArt.js` exists with `generate`, `listFonts`, `getFontMeta` API.
- [ ] 17 hand-coded fonts (5 existing + 12 new) registered, each renders the test word "HELLO" correctly.
- [ ] `figlet` library loads lazily on first `figlet:*` font request; lists ~400+ fonts via `listFonts()`.
- [ ] In-app modal (`#ascii-art-dialog`, `showASCIIGenerator*`, `preload.js:294-296`) is fully deleted; `git grep` returns zero hits for these symbols outside the deletion commit.
- [ ] Standalone window has Copy to Clipboard, Save to File, and Insert buttons. All three work end-to-end.
- [ ] Last-used font is remembered across window opens via `electron-store` `ascii:lastFont`.
- [ ] `npm test` passes with the new test files added.
- [ ] `npm run lint` clean, `npm run format:check` clean.
- [ ] `npm run build:linux` succeeds (no native-binding surprises from `figlet`).
- [ ] README updated to mention the expanded font catalog and the new output destinations.
@@ -0,0 +1,193 @@
# MarkdownConverter — Flow Chart Editor Design
**Date:** 2026-09-14
**Status:** Draft — pending review
**Author:** Amit Haridas
## Overview
Add a sidebar-panel flow chart editor that lets users build flowcharts visually (drag nodes, connect edges, edit labels in-place) without writing Mermaid syntax by hand. The editor produces Mermaid `flowchart` source, which the existing preview pane already renders natively. Insert at cursor writes a ```` ```mermaid ```` fenced block. The feature is purely renderer-side — no main-process changes needed because Mermaid is already a bundled dependency.
## Goals
1. **Visual node editor.** Click to add nodes (5 shapes: process, decision, terminator, subroutine, document). Drag to move. Double-click to edit label inline. Right-click for shape submenu.
2. **Edge editor.** Drag from node edge handle to another node to connect. Click edge to set type (solid arrow, dotted arrow, thick arrow) and add a label.
3. **Live Mermaid preview.** Right pane of the sidebar panel shows the generated Mermaid source and re-renders the actual SVG on every change (debounced 250 ms). User can copy the source or insert it at the cursor.
4. **Undo / redo.** `Ctrl+Z` / `Ctrl+Shift+Z` within the panel. Snapshot-based, bounded depth (50 steps).
5. **Persistence.** The current flowchart is auto-saved to `<userData>/flowchart-session.json` so reopening the panel restores the user's work.
6. **Comprehensive tests.** Pure-data module (`flowchart-store.js`) and the Mermaid translator (`flowchart-mermaid.js`) are fully unit-tested. The canvas (`flowchart-canvas.js`) is DOM-tested with jsdom pointer events.
## Non-Goals (v1)
- No swimlanes / subgraphs (Mermaid supports them; deferring for v2).
- No collaboration / multi-user editing.
- No export to PNG/SVG (the preview pane already renders; "Save as SVG" is a thin wrapper that can come later).
- No import of existing Mermaid source (parse, validate, place into canvas) — defer to v2.
- No theme sync (the SVG renders in the preview pane, which already respects the active theme).
- No infinite canvas / zoom-pan — fixed viewport sized to the panel.
- No copy/paste of nodes between editor instances.
## Decisions Locked
| Decision | Choice | Reason |
|---|---|---|
| Side location | New sidebar panel `flowchart` | Matches the established `src/sidebar/<name>-panel.js` pattern (`search-panel`, `daily-notes-panel`, `git-panel`) |
| Canvas technology | SVG (no D3, no Konva, no library) | Hand-rolled SVG keeps the bundle small; the canvas is bounded (~100 nodes max); drag/drop is straightforward with pointer events |
| Graph model | Pure data structure (nodes + edges) stored in a single `flowchart-store.js` module | Matches the codebase's "pure module + injectable IO" pattern; trivially testable |
| Node shapes | 5 SVG shape templates per type, sized to text width | Mermaid supports many shapes; 5 covers 95% of real flowcharts |
| Edge routing | Straight lines between node centers (no orthogonal/Manhattan routing) | Simpler; orthogonal routing can come in v2; the visual is still clear |
| Undo/redo | Snapshot stack with bounded depth 50 | Predictable memory; matches editor undo conventions |
| Persistence | Auto-save to `<userData>/flowchart-session.json` on every change (debounced 500 ms) | Survives app restart; user doesn't lose work |
| Mermaid emission | Always `flowchart TD` (top-down) for v1 | Top-down is the most common flowchart direction; horizontal can be a per-node option in v2 |
| Debouncing | Preview re-render 250 ms; persistence 500 ms | Responsive but not jittery |
| Testing | Pure store/mermaid unit tests; canvas DOM tests with jsdom | No real-browser testing needed |
| Accessibility | Keyboard shortcuts for add/delete/undo; visible focus rings on nodes; ARIA labels on the SVG | The panel is mouse-first but keyboard-accessible |
## Architecture
```
┌──────────────────────────────────────────┐
│ src/sidebar/flowchart-panel.js │
│ - mounts the panel │
│ - splits left (canvas) | right (preview)│
│ - wires pointer events to canvas │
│ - subscribes to store changes │
└──────────┬────────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ flowchart- │ │ flowchart-mermaid.js│
│ canvas.js │ │ - toMermaid(graph) │
│ - renders SVG │ │ - validate(shape) │
│ - pointer events │ └─────────────────────┘
│ - hit-testing │
└────────┬─────────┘
│ reads/writes
▼
┌──────────────────────────────────────────┐
│ flowchart-store.js (pure module) │
│ - graph { nodes, edges } │
│ - addNode / moveNode / removeNode │
│ - connect / disconnect / setEdgeType │
│ - undo / redo (snapshot stack) │
│ - subscribe(fn) → emits on change │
│ - serialize() / deserialize(json) │
│ - persistence IO injected │
└──────────────────────────────────────────┘
│
│ debounced 500 ms write
▼
┌──────────────────────────────────────────┐
│ <userData>/flowchart-session.json │
└──────────────────────────────────────────┘
```
## New Modules
| File | Role |
|---|---|
| `src/sidebar/flowchart-panel.js` | Renderer-only panel. Mounts the canvas + preview; registers keyboard shortcuts; wires the "Insert at Cursor" button. |
| `src/flowchart/flowchart-store.js` | Pure data module. The graph is `{ nodes: Node[], edges: Edge[] }`. Node: `{ id, kind, x, y, label }`. Edge: `{ id, fromNodeId, toNodeId, kind: 'solid'\|'dotted'\|'thick', label? }`. Exports `create()`, `addNode`, `moveNode`, `setNodeLabel`, `setNodeKind`, `removeNode`, `connect`, `disconnect`, `setEdgeKind`, `setEdgeLabel`, `undo`, `redo`, `subscribe`, `serialize`, `deserialize`, `toJSON`. Constructor takes injected IO `{ persistencePath, readFile, writeFile, now }`. |
| `src/flowchart/flowchart-canvas.js` | Renderer-only SVG canvas. Owns an `<svg>` element. Renders nodes as `<g>` containing shape `<path>`/`<rect>`/`<ellipse>` and a `<text>` label. Renders edges as `<line>` or `<polyline>`. Listens for pointer events: drag to move, double-click to edit label, right-click for shape menu. Hit-testing walks the DOM in reverse z-order. |
| `src/flowchart/flowchart-mermaid.js` | Pure translator. `toMermaid(graph)` returns the Mermaid `flowchart TD` source string. Validates each shape against the 5 supported kinds; throws on unknown kind. Handles label escaping (`"`, newlines → `\n`). |
| `src/flowchart/flowchart-shapes.js` | Pure module: `shapeSvg(kind, x, y, width, height) → string`. Defines the 5 SVG shape templates (process=rect, decision=diamond, terminator=stadium, subroutine=rect-with-double-border, document=parallelogram-approx). Pure functions; unit-tested. |
| `tests/flowchart-store.test.js` | Pure store tests: add/move/connect/disconnect/delete; undo/redo round-trip; subscribe fires once per change; serialize/deserialize round-trip; throws on invalid input. |
| `tests/flowchart-mermaid.test.js` | Translation tests: each node kind emits the correct Mermaid syntax (`[]`, `{}`, `(())`, `[[]]`, `[]/]`); edge kinds (`-->`, `-.->`, `==>`); label escaping. Snapshot tests for representative graphs. |
| `tests/flowchart-shapes.test.js` | Each shape function returns SVG that matches the expected viewBox and contains the expected primitive. |
| `tests/flowchart-canvas.test.js` | jsdom tests: mount canvas with a 3-node graph; assert SVG structure; simulate a `pointerdown` + `pointermove` + `pointerup`; assert store updated with new position. |
| `tests/flowchart-panel.test.js` | jsdom test: mount the panel; assert canvas + preview panes exist; assert preview re-renders on store change. |
| `tests/preload-flowchart.test.js` | (No new preload channels — feature is renderer-only.) |
## Modified Modules
| File | Change |
|---|---|
| `src/renderer.js:2300` (sidebar registration) | Add `sidebarManager.registerPanel('flowchart', { title: 'Flow Chart', render: renderFlowChartPanel, icon: '<svg>…flowchart icon…</svg>' })`. |
| `src/index.html` | Add a sidebar rail button with `data-panel="flowchart"` and the matching icon. |
| `src/styles/sidebar.css` (or wherever sidebar styles live) | Add minimal layout for the panel's split: `display: flex; flex: 1;` with left pane ~70% (canvas) and right pane ~30% (preview). |
| `README.md:54-70` (Advanced Features) | Add row: "**Visual flow chart editor** — Build Mermaid flowcharts visually; drag nodes, connect edges, live preview. Insert at cursor." |
| `README.md:120-128` (Keyboard Shortcuts) | Add row: `\| Add Flow Chart Node \| Insert (when panel focused) \|`, `\| Undo \| Ctrl+Z \|`, `\| Redo \| Ctrl+Shift+Z \|` (panel-scoped). |
## Node Shapes
| kind | Mermaid syntax | SVG shape | Use case |
|---|---|---|---|
| `process` | `A[Label]` | `<rect>` | Generic step |
| `decision` | `A{Label}` | `<polygon points="…">` (diamond) | Yes/no, branch |
| `terminator` | `A([Label])` | `<rect rx=…>` (stadium) | Start/end |
| `subroutine` | `A[[Label]]` | `<rect>` with double border | Named subroutine call |
| `document` | `A[/Label/]` | `<polygon>` (parallelogram-approx) | Document/file reference |
Sizes auto-grow to fit the label text (measure with `getComputedTextLength()` on the `<text>` after first render).
## Edge Kinds
| kind | Mermaid syntax | SVG style |
|---|---|---|
| `solid` | `A --> B` | Solid `<line>` with marker arrow |
| `dotted` | `A -.-> B` | `stroke-dasharray="4,4"` |
| `thick` | `A ==> B` | `stroke-width="3"` |
Edge labels render as `<text>` at the midpoint of the edge with a small white background rect for legibility.
## Data Flow
1. User opens the panel via sidebar rail button (`data-panel="flowchart"`).
2. `flowchart-panel.js` constructs a `flowchart-store.js` instance, calls `deserialize()` with whatever is in `<userData>/flowchart-session.json` (empty graph if absent).
3. Panel mounts the canvas SVG and the preview pane side by side.
4. Canvas subscribes to store changes; on every change, re-renders the SVG and notifies the panel.
5. Panel debounces (250 ms) and calls `flowchart-mermaid.toMermaid(graph)`; updates the preview pane.
6. Panel debounces (500 ms) and calls `store.serialize()` → writes to `<userData>/flowchart-session.json` via injected `writeFile`.
7. User clicks "Insert at Cursor": panel emits `insert-content` IPC with the Mermaid source wrapped in a fenced code block (same pattern as ASCII art's insert).
8. User triggers undo/redo: panel sends `Ctrl+Z` / `Ctrl+Shift+Z` to the canvas's keyboard handler when the panel is focused (the editor's existing undo is left alone — undo only fires when the panel has focus).
## Error Handling
| Scenario | Handling |
|---|---|
| Persistence file is corrupt JSON | `deserialize()` catches, logs warning, returns empty graph. User loses prior session but can start fresh. |
| Persistence write fails (disk full, perms) | `writeFile` rejects; the panel logs the error but continues functioning. The next save attempt retries. |
| `toMermaid()` encounters unknown node kind | Throws a typed error; the preview pane shows "Invalid graph: <message>"; the canvas still renders. User must fix the kind (e.g. via right-click menu). |
| `toMermaid()` label contains newlines | Escapes `\n` to literal `\n` (Mermaid's escape). |
| Canvas hit-test misidentifies a node | Defensive: the canvas uses `data-node-id` attribute on every `<g>`; lookup by id is O(1). |
| User adds a node with empty label | Allowed; rendered as a single space. Mermaid will accept it. |
| User tries to connect a node to itself | `connect()` throws `TypeError`; canvas shows toast. |
| Undo stack overflow (>50 entries) | Oldest snapshot dropped; no memory growth. |
## Testing
1. **Unit (`tests/flowchart-store.test.js`)** — Pure store operations. 30+ tests across add/move/connect/disconnect/delete/undo/redo/subscribe/serialize/deserialize. Includes invalid-input rejection.
2. **Unit (`tests/flowchart-mermaid.test.js`)** — Translation for each shape kind × each edge kind × with-label × without-label. Snapshot tests for 5 representative graphs (linear chain, decision diamond, parallel branches, cycle, large 20-node graph).
3. **Unit (`tests/flowchart-shapes.test.js`)** — `shapeSvg('process', 10, 10, 100, 50)` returns SVG containing a `<rect>` at the right coordinates.
4. **DOM (`tests/flowchart-canvas.test.js`)** — Mount with 3-node graph; assert `<svg>` contains 3 `<g data-node-id="…">`. Simulate `pointerdown` on node 1's center, `pointermove` +100px, `pointerup`; assert store's `moveNode` was called with the new position.
5. **DOM (`tests/flowchart-panel.test.js`)** — Mount panel; assert two panes exist; trigger a store change; assert preview re-renders within 300 ms (jest fake timers).
## Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Drag/drop in jsdom is fragile (no real layout) | Medium | Use `getBoundingClientRect()` mocks; canvas tests focus on store wiring, not pixel-perfect rendering. |
| Hand-rolled SVG shapes look amateurish next to Mermaid's rendered output | Medium | Use the same color tokens as the active theme via CSS variables; reuse `--accent`, `--text-primary` from the theme CSS. |
| Undo/redo memory growth on large graphs | Low | Bounded depth (50 snapshots) + snapshot diff-based compression (only store changed nodes). |
| Persistence path conflicts on multi-window future | Low | Use `<userData>/flowchart-session.json` — single user, single app instance. |
| Mermaid preview re-render flickers on every keystroke | Medium | Debounce 250 ms; show a "Rendering…" status indicator during re-render. |
| Edge routing looks ugly on dense graphs | Medium | Document as a known limitation in the panel tooltip; v2 orthogonal routing is on the roadmap. |
## Acceptance Criteria
- [ ] `src/sidebar/flowchart-panel.js` exists and is registered via `sidebarManager.registerPanel('flowchart', …)`.
- [ ] Sidebar rail button with `data-panel="flowchart"` added to `src/index.html`; clicking it opens the panel.
- [ ] User can add a node by clicking the canvas (default: process shape at click position).
- [ ] User can drag a node to a new position; the store's `moveNode` is called with correct coordinates.
- [ ] User can double-click a node to edit its label inline; on blur or Enter, the store is updated.
- [ ] User can right-click a node to change its shape (5 options: process, decision, terminator, subroutine, document).
- [ ] User can drag from a node's edge handle to another node to create an edge.
- [ ] User can click an edge to change its type (3 options: solid, dotted, thick) and add an optional label.
- [ ] `Ctrl+Z` / `Ctrl+Shift+Z` (when panel is focused) undoes / redoes the last change.
- [ ] The right preview pane shows the current Mermaid source as text AND renders the SVG via the existing Mermaid render path.
- [ ] "Insert at Cursor" wraps the Mermaid source in a ```` ```mermaid ```` fenced code block and inserts it at the editor cursor.
- [ ] Closing and reopening the app restores the last graph from `<userData>/flowchart-session.json`.
- [ ] `npm test` passes with the new test files added (target +30 new tests across 5 files).
- [ ] `npm run lint` clean, `npm run format:check` clean.
- [ ] `npm run build:linux` succeeds.
- [ ] README updated to mention the visual flow chart editor.
@@ -0,0 +1,158 @@
# MarkdownConverter — Editor Theme Registry Design
**Date:** 2026-09-14
**Status:** Draft — pending review
**Author:** Amit Haridas
## Overview
Replace the hardcoded editor-theme menu array in `main.js` (25 inline items at `src/main.js:1137-1245`) and the scattered CSS (`body.theme-<id>` selector blocks across `styles.css`, `styles-modern.css`, `styles-concreteinfo.css`) with a single `ThemeRegistry` module and a per-theme CSS file convention. Add 12 new themes spanning popular-requested, high-contrast, and seasonal categories. Future theme additions become a one-line `register()` call plus one CSS file.
## Goals
1. New themes can be added by changing one JS file and adding one CSS file — no menu edits.
2. Add 12 new themes spanning popular-requested (Catppuccin palette), high-contrast (Solarized Dark HC), and seasonal categories.
3. Theme application stays sub-100ms on theme switch (current behaviour).
4. Existing 25 themes keep working — pure refactor for them, additive for the 12 new ones.
5. Theme persistence model unchanged — `electron-store` `theme` key, default `atomonelight`.
## Non-Goals (v1)
- No user-defined custom themes (theme editor) — only shipped themes.
- No per-syntax-token color customization beyond the per-theme palette.
- No theme auto-switching based on system light/dark mode.
- No export-theme changes — `ExportThemes.js` is a separate system with its own registry (already exists).
## Decisions Locked
| Decision | Choice | Reason |
|---|---|---|
| Module location | `src/main/ThemeRegistry.js` (pure, no Electron) | Matches the established `DocQA`/`DailyNotes`/`WorkspaceSearch` pattern |
| Persistence | Existing `electron-store` `theme` key | Already wired; no migration needed |
| CSS organisation | One file per theme at `src/styles/themes/<id>.css` | Convention beats scattered selector blocks |
| CSS loading | Preload ALL theme `<link>` tags in `index.html`; toggle `disabled` on theme switch | Avoids link-swap network roundtrip; < 5 KB per theme × 37 = ~185 KB total CSS overhead |
| Menu generation | `main.js` calls `ThemeRegistry.list()` + `categories()` to build menu items | Removes the 109-line hardcoded block |
| Theme id format | kebab-case, lowercase, no spaces (`catppuccin-mocha`, `winter-is-coming-light`) | Stable contract for storage + CSS file naming |
| New themes | 12 new: 4× Catppuccin, 1× One Light, 1× Tokyo Night Storm, 2× Synthwave/Outrun, 2× Winter is Coming, 1× Solarized Dark HC, 1× Solarized Light (seasonal Spring variant) | Highest demand per GitHub issues + aesthetic completeness |
## Architecture
```
┌─────────────────────────┐
electron-store ───► │ src/main/ThemeRegistry │ ◄── register() at startup
(theme = "x") │ .js (pure module) │
│ list() │
│ get(id) │
│ categories() │
└──────────┬──────────────┘
│ list()
▼
main.js menu builder
│
┌──────────┴───────────┐
│ │
▼ ▼
"Light Themes" "Dark Themes"
"High-Contrast" "Seasonal"
│ │
└──────────┬───────────┘
│ IPC: theme-changed
▼
renderer.js applyTheme(id)
│
▼
index.html has 37 <link rel="stylesheet"
href="styles/themes/<id>.css" disabled
(all themes preloaded)
│
▼
applyTheme() flips `disabled` on the
matching <link>, removes from others
```
## New Modules
| File | Role |
|---|---|
| `src/main/ThemeRegistry.js` | Pure module: `register(theme)`, `unregister(id)`, `list()`, `get(id)`, `categories()`, `lightThemes()`, `darkThemes()`. A theme is `{ id, label, category: 'light'\|'dark'\|'high-contrast'\|'seasonal', isDark: boolean }`. No Electron imports. |
| `src/main/ThemeRegistry.bootstrap.js` | Calls `register()` for all 37 themes (25 existing + 12 new). Imported by `main.js` at startup. |
| `src/styles/themes/<id>.css` × 37 | Per-theme CSS rules previously scattered. Each file's body selector is `body.theme-<id>`. Naming convention: `<id>.css`. |
| `tests/theme-registry.test.js` | Register / unregister / list / get / categories; lightThemes/darkThemes filtering; throws on duplicate id. |
| `tests/theme-menu-builder.test.js` | Generates MenuItem[] from `list()` + `categories()`: each item has `{ id, label, type: 'radio', checked, click: () => setTheme(id) }`. |
## Modified Modules
| File | Change |
|---|---|
| `src/main.js:1137-1245` | Delete the 109-line hardcoded theme menu array. Replace with: `const themeMenu = buildThemeMenu(store, mainWindow)` (new helper, ~25 lines, lives next to the menu builder). |
| `src/main.js:3952-3955` | `setTheme(id)` already validates against a hardcoded set — replace hardcoded list with `ThemeRegistry.get(id) ? id : 'atomonelight'`. |
| `src/main.js` startup | Add `require('./main/ThemeRegistry.bootstrap')` once. |
| `src/index.html` | Replace the bare `<link rel="stylesheet" href="styles.css">` block with: preload ALL theme CSS as `<link id="theme-<id>" rel="stylesheet" href="styles/themes/<id>.css" disabled>` (one per theme). The base `styles.css` link stays as the structural (non-themed) layer. |
| `src/renderer.js:2900` (`get-theme` IPC) | No change — handler already returns stored theme. |
| `src/renderer.js:3005-3007` (apply theme) | Replace `document.body.className = 'theme-<id>'` with: find the matching `<link id="theme-<id>">`, set `disabled = false`; find the previously active `<link>`, set `disabled = true`. Keep the `theme-<id>` body className for any rule that depends on it (we keep the convention). |
| `src/styles.css` + `styles-modern.css` + `styles-concreteinfo.css` | Remove the inlined `body.theme-<id> { … }` blocks (one per existing 25 themes). Move them to `src/styles/themes/<id>.css`. Pure code-motion — no rules change. |
| `README.md:129-159` (Themes section) | Update list to 37 themes, mention new categories. |
## New Themes (12)
| id | label | category | notes |
|---|---|---|---|
| `catppuccin-latte` | Catppuccin Latte | light | Warm pastel |
| `catppuccin-frappe` | Catppuccin Frappé | dark | Muted pastels |
| `catppuccin-macchiato` | Catppuccin Macchiato | dark | Medium contrast |
| `catppuccin-mocha` | Catppuccin Mocha | dark | High contrast pastels |
| `one-light` | One Light | light | Atom One Light sibling |
| `tokyo-night-storm` | Tokyo Night Storm | dark | Variant of existing Tokyo Night |
| `synthwave-84` | Synthwave '84 | dark | Neon-on-dark |
| `outrun` | Outrun | dark | Magenta/cyan |
| `winter-is-coming-light` | Winter is Coming (Light) | light | Light variant of existing dark |
| `winter-is-coming-dark` | Winter is Coming (Dark) | dark | New dark variant |
| `solarized-dark-hc` | Solarized Dark (High Contrast) | high-contrast | Accessibility-focused |
| `spring-light` | Spring Light | seasonal | First seasonal theme; same palette as Solarized Light but with green accents |
Categories are independent from `isDark`; e.g. `winter-is-coming-light` has `isDark: false` and `category: 'light'` but conceptually belongs to the "winter is coming" family. Future seasonal themes can share a `family: 'winter-is-coming'` field without changing the public API.
## Data Flow
1. App starts → `main.js` requires `ThemeRegistry.bootstrap.js` → calls `register()` for each of the 37 themes.
2. `main.js` menu builder reads `ThemeRegistry.list()` + `categories()`, generates the View → Theme submenu programmatically.
3. Renderer on init sends `get-theme` IPC → `main.js` replies with stored theme id.
4. `renderer.js:3005-3007` `applyTheme(id)` toggles `disabled` on the matching `<link id="theme-<id>">`; sets `body.className = 'theme-<id>'` for legacy rule compatibility.
5. User picks a new theme in menu → main process `setTheme(id)` → persists via `electron-store` → broadcasts `theme-changed` IPC → renderer `applyTheme(id)` re-toggles.
## Error Handling
| Scenario | Handling |
|---|---|
| Stored theme id no longer exists (e.g. after downgrade) | `setTheme()` falls back to `atomonelight`; logs a warning. Renderer also re-applies defensively. |
| Missing theme CSS file (e.g. partial install) | `<link disabled>` simply doesn't activate; no JS error. App continues with previous active theme. |
| `register()` called with duplicate id | Throws synchronously — startup fails loudly rather than silently shadowing an existing theme. |
| Theme CSS file fails to parse (syntax error) | Browser logs error to console; the disabled `<link>` is never activated so no visual breakage. |
## Testing
1. **Unit (`tests/theme-registry.test.js`)** — `register` adds to list, `unregister` removes, `get` returns the theme, `categories()` returns unique categories in registration order, `lightThemes()`/`darkThemes()` filter correctly. Throws on duplicate id. Throws on missing id in `get()`.
2. **Unit (`tests/theme-menu-builder.test.js`)** — Given a list of 5 sample themes (2 light, 2 dark, 1 hc), `buildThemeMenu` returns a `MenuItem[]` with the correct structure: submenu labels, accelerators, `type: 'radio'`, `checked` matching current selection, `click` handlers wired.
3. **Snapshot (`tests/theme-registry-bootstrap.test.js`)** — At startup, `ThemeRegistry.list()` returns exactly 37 themes with the expected ids in the expected order.
4. **Existing tests** — `tests/project-meta.test.js` already checks `v${version}` in README (now 4.8.0); unchanged.
## Risks
| Risk | Likelihood | Mitigation |
|---|---|---|
| Per-theme CSS files duplicate common rules across 37 files | Medium | Factor the shared editor-surface rules (caret, selection, scrollbar) into a `styles/themes/_base.css`; per-theme files only carry palette tokens. |
| Preloading 37 `<link>` tags delays first paint | Low | Total CSS ≈ 185 KB; gzip ≈ 30 KB; below the 100 ms first-paint budget on Electron's bundled Chromium. Measure with `webContents.getPrintersAsync`-style timing if needed. |
| Body className + disabled-link double-state could drift | Low | Single function `applyTheme(id)` is the only mutation path; unit-tested with both states asserted. |
| README Themes list grows unwieldy at 37 | Low | Group by category in the README; collapse to one-liners per theme with a table. |
## Acceptance Criteria
- [ ] `src/main/ThemeRegistry.js` exists with the public API documented.
- [ ] 37 themes registered at startup; `list().length === 37`.
- [ ] `main.js` menu builder produces the View → Theme submenu from the registry (no hardcoded theme names).
- [ ] Renderer switches themes by toggling `<link disabled>`; no full page reload.
- [ ] Theme persists across restarts via `electron-store`.
- [ ] All existing 25 themes look identical to v4.8.0 (visual regression by inspection — themes are CSS, no logic change).
- [ ] `npm test` passes with the new theme-registry + menu-builder test files added.
- [ ] `npm run lint` clean, `npm run format:check` clean.
- [ ] README updated to list all 37 themes by category.
+25
View File
@@ -36,6 +36,16 @@ module.exports = {
copyright: 'Copyright (C) 2024-2025 ConcreteInfo', copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
directories: { output: 'dist' }, directories: { output: 'dist' },
icon: 'assets/icon', icon: 'assets/icon',
// v4.13.0 — auto-update wiring via electron-updater. `provider: 'github'`
// is the no-config option: it reads releases from this repo's Releases
// page and matches by the tag's semver against package.json. Replace
// with `generic` + `url:` if you ever move off GitHub releases.
publish: {
provider: 'github',
owner: 'concreteinfo',
repo: 'markdownconverter',
releaseType: 'release',
},
files: [ files: [
'src/**/*', 'src/**/*',
'assets/**/*', 'assets/**/*',
@@ -115,6 +125,21 @@ module.exports = {
category: 'Utility', category: 'Utility',
maintainer: 'ConcreteInfo <amit.wh@gmail.com>', maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
extraFiles: toolExtraFiles('linux'), extraFiles: toolExtraFiles('linux'),
// v4.13.0 — keep the generated .desktop file's StartupWMClass in sync
// with the top-level desktopName in package.json so DEs can link the
// running window to the launcher icon. desktopName itself is read
// from package.json (electron-builder merges package.json fields into
// the linux config automatically).
syncDesktopName: true,
},
// v4.13.0 — Snap packaging defaults. `grade: stable` is required for
// the auto-updater to push updates to the stable track from GitHub
// releases (electron-updater expects stable-grade snaps). We let
// snapcraft pick its default channel set — the publish-side channels
// live in `snapcraft.yaml` / the Snap Store, not in this config.
snap: {
confinement: 'strict',
grade: 'stable',
}, },
deb: { deb: {
depends: ['imagemagick', 'libreoffice-common'], depends: ['imagemagick', 'libreoffice-common'],
+6
View File
@@ -62,6 +62,12 @@ module.exports = [
HTMLInputElement: 'readonly', HTMLInputElement: 'readonly',
HTMLTextAreaElement: 'readonly', HTMLTextAreaElement: 'readonly',
getComputedStyle: 'readonly', getComputedStyle: 'readonly',
CSS: 'readonly',
Element: 'readonly',
// Modern Web APIs available in Node 20+ and Chromium
AbortController: 'readonly',
TextDecoder: 'readonly',
TextEncoder: 'readonly',
// Electron // Electron
electronAPI: 'readonly', electronAPI: 'readonly',
// Libraries // Libraries
+1910 -2542
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -1,8 +1,12 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.7.1", "version": "4.13.0",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
"main": "src/main.js", "main": "src/main.js",
"desktopName": "MarkdownConverter",
"engines": {
"node": ">=22.0.0"
},
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest", "test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
@@ -25,6 +29,7 @@
"dist:all": "electron-builder --config electron-builder.config.js -mwl", "dist:all": "electron-builder --config electron-builder.config.js -mwl",
"download-tools": "node scripts/download-tools.js", "download-tools": "node scripts/download-tools.js",
"bundle:markitdown": "node scripts/bundle-markitdown.js", "bundle:markitdown": "node scripts/bundle-markitdown.js",
"build:bundle": "node scripts/build-flowchart-bundle.js",
"generate-icons": "node scripts/generate-icons.js" "generate-icons": "node scripts/generate-icons.js"
}, },
"keywords": [ "keywords": [
@@ -77,7 +82,9 @@
"docx": "^9.6.0", "docx": "^9.6.0",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"electron-store": "^10.1.0", "electron-store": "^10.1.0",
"electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0", "ffmpeg-static": "^5.3.0",
"figlet": "^1.8.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2pdf.js": "^0.14.0", "html2pdf.js": "^0.14.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
// v4.13.1 — Flowchart bundle generator.
//
// Reads the pure modules from src/flowchart/ and the unique "tail"
// (controller bootstrap + modal helpers + node-list panel) from
// src/renderer/flowchart-bundle-tail.js, then concatenates them into
// the final src/renderer/flowchart-bundle.js that the standalone
// BrowserWindow loads as a single <script> tag.
//
// Why this exists: before this script, the bundle manually inlined
// ~1100 lines of pure modules at the top. Every change to a pure
// module required a manual re-sync, which led to drift (the C17
// multi-select change had to be applied in two places, and a missed
// sync would silently break the standalone window).
//
// Usage: npm run build:bundle
// Output: src/renderer/flowchart-bundle.js
//
// @module build-flowchart-bundle
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const FLOWCHART_DIR = path.join(SRC, 'flowchart');
const TAIL_PATH = path.join(SRC, 'renderer', 'flowchart-bundle-tail.js');
const OUTPUT_PATH = path.join(SRC, 'renderer', 'flowchart-bundle.js');
// Pure modules in load order. flowchart-vsdx-export is intentionally
// excluded — it runs in the main process only (consumed by the IPC
// handler), never by the renderer. The remaining modules are
// collision-free at the top level (each declares its own unique
// constants) so they can be concatenated without IIFE wrapping.
const PURE_MODULES = [
'flowchart-shapes.js',
'flowchart-viewport.js',
'flowchart-mermaid.js',
'flowchart-mermaid-parse.js',
'flowchart-clipboard.js',
'flowchart-store.js',
'flowchart-canvas.js',
'flowchart-align.js',
];
function readModule(filename) {
const filePath = path.join(FLOWCHART_DIR, filename);
if (!fs.existsSync(filePath)) {
throw new Error('build-flowchart-bundle: missing pure module ' + filePath);
}
let source = fs.readFileSync(filePath, 'utf-8');
const basename = filename.replace(/\.js$/, '');
// Camel-case the basename for the window global: flowchart-mermaid-parse
// -> FlowchartMermaidParse, flowchart-clipboard -> FlowchartClipboard.
const windowName = 'window.Flowchart' + basename
.replace(/^flowchart-/, '')
.split('-')
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
// v4.13.1 — strip CommonJS-isms before concatenating into the bundle.
// The bundle is a browser-only script (no Node `module` / `require`),
// so we have to:
// 1. Replace `const shapesModule = (typeof window !== 'undefined'
// && window.FlowchartShapes) || require('./flowchart-shapes')`
// with the pure browser form so canvas can still pick up
// SHAPE_KINDS / DEFAULT_WIDTH / etc. from the shapes module
// that was inlined just above.
// 2. Replace any top-level `module.exports = ...` lines that some
// modules (mermaid-parse, vsdx-export) have without a UMD
// guard with an equivalent `window.X = ...` assignment so the
// bundle's tail can still find the module's exports.
// The CommonJS paths stay intact for tests + the sidebar panel.
if (filename === 'flowchart-canvas.js') {
source = source.replace(
/const shapesModule =\s*\n?\s*\(typeof window[\s\S]*?require\('\.\/flowchart-shapes'\);/,
'const shapesModule = window.FlowchartShapes;'
);
source = source.replace(
/const viewportModule =\s*\n?\s*\(typeof window[\s\S]*?require\('\.\/flowchart-viewport'\);/,
'const viewportModule = window.FlowchartViewport;'
);
}
// Replace bare `module.exports = X;` with `window.X = X;` so the
// tail can pick up the module's exports from window globals.
source = source.replace(
/^module\.exports\s*=\s*([^\n;]+);?\s*$/gm,
windowName + ' = $1;'
);
source = source.replace(/\n{3,}/g, '\n\n');
return source;
}
function buildHeader() {
const parts = [
'// v4.13.1 — AUTO-GENERATED FILE. DO NOT EDIT.',
'//',
'// This file is the concatenation of every pure module in',
'// src/flowchart/ plus the unique tail from',
'// src/renderer/flowchart-bundle-tail.js. Regenerate it by running',
"// `npm run build:bundle` whenever a pure module changes.",
'//',
'// Each pure module is wrapped in its own IIFE so top-level',
'// `const` declarations (DEFAULT_WIDTH, DEFAULT_HEIGHT, SHAPE_KINDS,',
'// etc.) stay local to that module and do not collide with',
'// identically-named declarations in other modules. The UMD',
'// wrappers inside each module continue to expose the public API',
'// as window.FlowchartXxx, which the tail consumes.',
];
for (const filename of PURE_MODULES) {
const basename = filename.replace(/\.js$/, '');
parts.push('');
parts.push(' // ========== ' + basename + ' (inlined by build script) ==========');
// Wrap each module in its own IIFE for scope isolation. The UMD
// wrapper inside still runs and assigns to window.FlowchartXxx
// on the outer window object.
parts.push('(function () {');
parts.push(readModule(filename));
parts.push('})();');
}
return parts.join('\n');
}
function buildTail() {
if (!fs.existsSync(TAIL_PATH)) {
throw new Error('build-flowchart-bundle: missing tail ' + TAIL_PATH);
}
return fs.readFileSync(TAIL_PATH, 'utf-8');
}
function main() {
const header = buildHeader();
const tail = buildTail();
const output = header + '\n' + tail;
fs.writeFileSync(OUTPUT_PATH, output);
const stat = fs.statSync(OUTPUT_PATH);
console.log(
'build-flowchart-bundle: wrote ' +
path.relative(ROOT, OUTPUT_PATH) +
' (' +
stat.size +
' bytes, ' +
PURE_MODULES.length +
' pure modules + tail)'
);
}
if (require.main === module) {
try {
main();
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
module.exports = { main, PURE_MODULES };
+8 -3
View File
@@ -29,9 +29,14 @@ const FIRACODE_VERSION = '6.2';
const KNOWN_SHA256 = { const KNOWN_SHA256 = {
'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597', 'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597',
'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba', 'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba',
// Fill these from a trusted machine after the first download of each // darwin:pandoc is unpinned because this branch has no macOS host to
// platform (the script prints the computed hash): // compute the hash from. The verifyArtifact() function below prints a
// 'darwin:pandoc': '…', // clear warning + the computed hash when no pinned hash is present,
// and the CI release job (windows-latest + macos-latest) will surface
// it in the build log on the first macOS build — copy the printed
// hash back into this table to lock it in. Hard-fail on mismatch is
// already in place for the platforms we *can* verify from here.
// 'darwin:pandoc': '<fill from first macOS CI run>',
// Fira Code 6.2 (immutable GitHub release asset) // Fira Code 6.2 (immutable GitHub release asset)
'fonts:FiraCode-Regular.ttf': '5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117', 'fonts:FiraCode-Regular.ttf': '5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117',
'fonts:FiraCode-Bold.ttf': '41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be', 'fonts:FiraCode-Bold.ttf': '41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be',
+180
View File
@@ -0,0 +1,180 @@
/**
* Inline AI assist popover.
*
* Floating UI anchored to the current text selection. Three action buttons
* (Rewrite / Shorten / Expand) + loading + error states + cancel/dismiss.
*
* The popover owns DOM only; the network call lives in the renderer's
* `aiAssist` preload bridge and the IPC layer in main.js. The popover
* dispatches actions via callbacks so the renderer can wire them to its
* own selection / CodeMirror state.
*
* @module inline-ai-popover
*/
/**
* @typedef {'idle' | 'loading' | 'error'} State
*/
/**
* @param {HTMLElement} container Mount point (typically document.body)
* @param {object} deps
* @param {(action:string, selection:string) => void} [deps.onAction]
* @param {() => void} [deps.onCancel]
* @param {() => void} [deps.onRetry]
* @param {(message:string) => void} [deps.onErrorShown]
* @returns {{
* show(selection:{text:string, rect:DOMRect}): void,
* hide(): void,
* setState(state:State, opts?:{message?:string}): void,
* isVisible(): boolean,
* destroy(): void,
* }}
*/
function createInlineAiPopover(container, deps = {}) {
const { onAction = () => {}, onCancel = () => {}, onRetry = () => {} } = deps;
let popoverEl = null;
let actionsEl = null;
let loadingEl = null;
let errorEl = null;
let errorMessageEl = null;
let visible = false;
let state = 'idle';
function buildDom() {
const wrap = document.createElement('div');
wrap.className = 'inline-ai-popover';
wrap.setAttribute('role', 'toolbar');
wrap.setAttribute('aria-label', 'AI assist');
wrap.innerHTML = `
<div class="inline-ai-state inline-ai-actions">
<button type="button" data-action="rewrite">Rewrite</button>
<button type="button" data-action="shorten">Shorten</button>
<button type="button" data-action="expand">Expand</button>
</div>
<div class="inline-ai-state inline-ai-loading" hidden>
<span class="inline-ai-spinner" aria-hidden="true"></span>
<span class="inline-ai-loading-label">Thinking…</span>
<button type="button" data-action="cancel">Cancel</button>
</div>
<div class="inline-ai-state inline-ai-error" hidden>
<span class="inline-ai-error-message"></span>
<button type="button" data-action="retry">Retry</button>
<button type="button" data-action="dismiss">Dismiss</button>
</div>
`;
return wrap;
}
function attachListeners() {
popoverEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'cancel' || action === 'dismiss') {
onCancel();
return;
}
if (action === 'retry') {
onRetry();
return;
}
if (state === 'idle') {
// Stash the selection text on the popover at show() time so the
// action handler doesn't need to query the DOM itself.
const sel = popoverEl._inlineAiSelection || '';
onAction(action, sel);
}
});
}
function position(rect) {
if (!popoverEl || !rect) return;
const margin = 8;
const popWidth = popoverEl.offsetWidth || 240;
const popHeight = popoverEl.offsetHeight || 36;
let top = rect.top - popHeight - margin;
let left = rect.left + rect.width / 2 - popWidth / 2;
// Flip below the line if there's no room above
if (top < margin) {
top = rect.bottom + margin;
}
// Clamp horizontally to the container's bounds
const containerRect = container.getBoundingClientRect
? container.getBoundingClientRect()
: { left: 0, width: window.innerWidth };
const minLeft = containerRect.left + margin;
const maxLeft = containerRect.left + containerRect.width - popWidth - margin;
if (left < minLeft) left = minLeft;
if (left > maxLeft) left = maxLeft;
popoverEl.style.top = `${top + window.scrollY}px`;
popoverEl.style.left = `${left + window.scrollX}px`;
}
function setStateEl(newState, opts = {}) {
if (!popoverEl) return;
state = newState;
actionsEl.hidden = newState !== 'idle';
loadingEl.hidden = newState !== 'loading';
errorEl.hidden = newState !== 'error';
popoverEl.dataset.state = newState;
if (newState === 'error' && errorMessageEl) {
errorMessageEl.textContent = opts.message || 'AI request failed.';
}
}
function show(selection) {
if (!popoverEl) {
popoverEl = buildDom();
container.appendChild(popoverEl);
actionsEl = popoverEl.querySelector('.inline-ai-actions');
loadingEl = popoverEl.querySelector('.inline-ai-loading');
errorEl = popoverEl.querySelector('.inline-ai-error');
errorMessageEl = popoverEl.querySelector('.inline-ai-error-message');
attachListeners();
}
popoverEl._inlineAiSelection = selection?.text || '';
setStateEl('idle');
popoverEl.classList.add('open');
visible = true;
// Position needs to happen after the DOM is in the document and laid out
if (selection?.rect) position(selection.rect);
}
function hide() {
visible = false;
if (popoverEl) popoverEl.classList.remove('open');
}
function destroy() {
if (popoverEl && popoverEl.parentNode) popoverEl.parentNode.removeChild(popoverEl);
popoverEl = null;
actionsEl = null;
loadingEl = null;
errorEl = null;
errorMessageEl = null;
}
function isVisibleFn() {
return visible;
}
function getRoot() {
return popoverEl;
}
return {
show,
hide,
setState: setStateEl,
isVisible: isVisibleFn,
destroy,
getRoot,
};
}
module.exports = { createInlineAiPopover };
+107
View File
@@ -0,0 +1,107 @@
/**
* Inline AI assist — prompt builder + result applier.
*
* Pure module. The actual network call goes through AiProviders.js; this
* module shapes the request (system + user messages) and validates the
* response before it reaches the editor.
*
* Actions (v4.13.0): rewrite / shorten / expand.
*
* Safety: MAX_SELECTION_CHARS caps the selection so a runaway or hostile
* caller can't push an 8KB+ blob at an API. The cap is enforced BEFORE
* the IPC hop so the main process sees only safe payloads.
*
* @module inline-assist
*/
const MAX_SELECTION_CHARS = 8 * 1024;
const ACTIONS = {
rewrite: {
label: 'Rewrite',
systemPrompt:
'You are a precise writing assistant. Rewrite the selected text to improve clarity, ' +
'flow, and word choice while preserving the original meaning, tone, and length. ' +
'Return ONLY the rewritten text — no preamble, no explanation, no surrounding quotes.',
},
shorten: {
label: 'Shorten',
systemPrompt:
'You are a precise writing assistant. Shorten the selected text while preserving all ' +
'key information. Aim for 30–50% reduction in length. Drop filler, redundancy, and ' +
'verbose phrasing. Return ONLY the shortened text — no preamble, no explanation.',
},
expand: {
label: 'Expand',
systemPrompt:
'You are a precise writing assistant. Expand the selected text by adding relevant ' +
'detail, examples, or explanation. Aim for 50–100% expansion. Stay on-topic. ' +
'Return ONLY the expanded text — no preamble, no explanation.',
},
};
const ACTIONS_LIST = Object.keys(ACTIONS);
/**
* Build the {system, messages} payload for `action`.
*
* @param {'rewrite'|'shorten'|'expand'} action
* @param {string} selection
* @returns {{system:string, messages:Array<{role:'user', content:string}>}}
* @throws when action is unknown or selection is empty / too large
*/
function buildAssistPrompt(action, selection) {
const def = ACTIONS[action];
if (!def) {
const err = new Error(`Unknown AI assist action: ${action}`);
err.code = 'unknown_action';
throw err;
}
if (typeof selection !== 'string') {
const err = new Error('Selection must be a string');
err.code = 'bad_selection';
throw err;
}
if (selection.length === 0) {
const err = new Error('Selection is empty');
err.code = 'empty_selection';
throw err;
}
if (selection.length > MAX_SELECTION_CHARS) {
const err = new Error(
`Selection is too large (${selection.length} chars; max ${MAX_SELECTION_CHARS})`
);
err.code = 'selection_too_large';
throw err;
}
return {
system: def.systemPrompt,
messages: [{ role: 'user', content: selection }],
};
}
/**
* Validate the provider's response before applying it to the editor.
*
* - Empty / whitespace-only → return null (don't replace selection with nothing)
* - Unchanged → return null (no-op, don't churn the editor)
*
* @param {string} originalText - The original selection
* @param {string} newText - The provider's response
* @returns {string|null} text to apply, or null to skip
*/
function applyAssistResult(originalText, newText) {
if (typeof newText !== 'string') return null;
const trimmed = newText.trim();
if (trimmed.length === 0) return null;
if (trimmed === String(originalText || '').trim()) return null;
return trimmed;
}
module.exports = {
buildAssistPrompt,
applyAssistResult,
ACTIONS,
ACTIONS_LIST,
MAX_SELECTION_CHARS,
};
+17 -395
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Art Generator - MarkdownConverter</title> <title>ASCII Art Generator - MarkdownConverter</title>
<link rel="stylesheet" href="../fonts.css" /> <link rel="stylesheet" href="fonts.css" />
<style> <style>
:root { :root {
--ci-dark-gray: #464646; --ci-dark-gray: #464646;
@@ -262,14 +262,15 @@
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Style</label> <label class="form-label">Font</label>
<select id="font-style" class="form-select"> <input
<option value="standard">Standard</option> type="text"
<option value="banner">Banner</option> id="font-search"
<option value="block">Block</option> class="form-input"
<option value="bubble">Bubble</option> placeholder="Search fonts…"
<option value="digital">Digital</option> autocomplete="off"
</select> />
<select id="font-picker" class="form-select" size="8"></select>
</div> </div>
</div> </div>
@@ -350,6 +351,10 @@
<div class="section"> <div class="section">
<div class="section-title">Preview</div> <div class="section-title">Preview</div>
<div
id="ascii-warning"
style="display: none; color: #e5461f; margin-bottom: 8px; font-size: 0.8rem"
></div>
<div class="preview-container"> <div class="preview-container">
<div id="preview" class="preview-content"></div> <div id="preview" class="preview-content"></div>
</div> </div>
@@ -358,394 +363,11 @@
<div class="footer"> <div class="footer">
<button class="btn btn-secondary" id="btn-generate">Generate Preview</button> <button class="btn btn-secondary" id="btn-generate">Generate Preview</button>
<button class="btn btn-secondary" id="btn-copy">Copy to Clipboard</button>
<button class="btn btn-secondary" id="btn-save">Save to File</button>
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button> <button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
</div> </div>
<script> <script src="renderer/ascii-controller.js"></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> </body>
</html> </html>
+8
View File
@@ -121,6 +121,7 @@ function createEditor(parentElement, options = {}) {
showLineNumbers = true, showLineNumbers = true,
vimMode = false, vimMode = false,
getTabExpansion = null, getTabExpansion = null,
smartPasteFetcher = null,
} = options; } = options;
const extensions = [ const extensions = [
@@ -155,6 +156,13 @@ function createEditor(parentElement, options = {}) {
EditorView.lineWrapping, EditorView.lineWrapping,
]; ];
// Smart-paste: a URL-only paste is intercepted and rewritten to a
// markdown link once the page title comes back from the main process.
// The host (renderer) injects the IPC-backed fetcher; null disables.
if (smartPasteFetcher) {
extensions.push(require('./smart-paste').smartPaste({ fetchTitle: smartPasteFetcher }));
}
if (showLineNumbers) { if (showLineNumbers) {
extensions.push(lineNumbers()); extensions.push(lineNumbers());
} }
+83
View File
@@ -0,0 +1,83 @@
/**
* CodeMirror 6 smart-paste extension.
*
* When the pasted text is a single URL (or a URL surrounded only by
* whitespace), intercept it and ask the main process for the page title
* via the `url-title:fetch` IPC channel. Replace the paste with a
* markdown link "[Title](url)" if a title is found, or leave the URL
* untouched if the fetch fails or returns no title.
*
* Other paste content passes through unchanged — we never want to mangle
* a multi-line paste just because one token looks URL-ish.
*
* @param {object} deps
* @param {(args:{url:string,timeoutMs?:number}) => Promise<{url:string,title:string}|null>} deps.fetchTitle
* @returns {import('@codemirror/view').Extension}
*/
const { EditorView } = require('@codemirror/view');
const { csvToTable, looksLikeCsv } = require('../utils/csv-to-table');
const URL_ONLY_RE = /^\s*(https?:\/\/[^\s]+)\s*$/i;
const TIMEOUT_MS = 4000;
function smartPaste(deps) {
const { fetchTitle } = deps || {};
if (typeof fetchTitle !== 'function') {
// No-op extension if no IPC bridge was passed in
return [];
}
return EditorView.domEventHandlers({
paste(event, view) {
const text = event.clipboardData && event.clipboardData.getData('text/plain');
if (!text) return;
// CSV/TSV flavor first — it's instant and self-contained (no async).
if (looksLikeCsv(text)) {
event.preventDefault();
const table = csvToTable(text);
if (!table) return;
const head = view.state.selection.main.head;
view.dispatch({
changes: { from: head, insert: table },
selection: { anchor: head + table.length },
});
return;
}
// URL flavor — paste the URL immediately, then rewrite with the title.
const urlMatch = URL_ONLY_RE.exec(text);
if (!urlMatch) return; // not our department — let the default handler run
const url = urlMatch[1];
event.preventDefault();
const head = view.state.selection.main.head;
const from = head;
view.dispatch({
changes: { from, insert: url },
selection: { anchor: from + url.length },
});
Promise.race([
fetchTitle({ url, timeoutMs: TIMEOUT_MS }),
new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
])
.then((result) => {
if (!result || !result.title) return;
const currentLen = view.state.doc.length;
const rewriteTo = Math.min(from + url.length, currentLen);
if (rewriteTo <= from) return;
const replacement = `[${result.title}](${url})`;
view.dispatch({
changes: { from, to: rewriteTo, insert: replacement },
selection: { anchor: from + replacement.length },
});
})
.catch(() => {
/* leave URL as-is */
});
},
});
}
module.exports = { smartPaste, URL_ONLY_RE };
+706
View File
@@ -0,0 +1,706 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flowchart Generator - MarkdownConverter</title>
<!--
v4.9.6 — Standalone Flowchart Generator window.
Stylesheets are src/-relative (mirrors src/index.html). NO ../ escape —
the standalone BrowserWindow's document base is src/, so a ../fonts.css
would resolve outside src/ and fail to load.
v4.11.0 — Added a node-list panel (#fc-nodelist) below the canvas
that exposes every mutation via buttons and form controls. The
canvas itself is purely visual now (the v4.10.0 floating selection
toolbar that fired on click hit-testing was unreliable in the
user's Electron runtime). Removed #fc-selection-toolbar.
v4.12.0 — Reorganised #fc-nodelist so the connect form is the
second section (right after Add Node) instead of being buried below
the node/edge lists; added per-node color picker; added
"Save to File" button next to "Insert at Cursor".
-->
<link rel="stylesheet" href="fonts.css" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="styles-modern.css" />
<link rel="stylesheet" href="styles-concreteinfo.css" />
<link rel="stylesheet" href="styles-sidebar.css" />
<link rel="stylesheet" href="styles-zen.css" />
<link rel="stylesheet" href="styles-welcome.css" />
<link rel="stylesheet" href="katex.min.css" />
<style>
:root {
--fc-bg: #fafafa;
--fc-text: #1f2328;
--fc-border: #d0d7de;
--fc-accent: #e5461f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--fc-bg);
color: var(--fc-text);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.fc-header {
background: linear-gradient(135deg, #464646 0%, #0d0b09 100%);
padding: 12px 20px;
border-bottom: 3px solid var(--fc-accent);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.fc-header h1 {
color: #fff;
font-size: 1.05rem;
font-weight: 600;
}
.fc-hint {
color: #9a9696;
font-size: 0.75rem;
}
.fc-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--fc-border);
background: #fff;
}
.fc-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
border: 1px solid var(--fc-border);
background: #fff;
color: var(--fc-text);
}
.fc-btn:hover {
border-color: var(--fc-accent);
}
.fc-btn.primary {
background: var(--fc-accent);
color: #fff;
border-color: var(--fc-accent);
}
.fc-btn.primary:hover {
background: #c93a18;
}
.fc-status {
margin-left: auto;
font-size: 0.75rem;
color: #6e7681;
}
/* v4.12.0 — the status line moved inside #fc-nodelist (no longer a
* flex toolbar child), so `margin-left: auto` is a no-op there. Reset
* it explicitly so the status sits flush-left below the toolbar row. */
.fc-nodelist .fc-status {
margin-left: 0;
display: block;
min-height: 1em;
}
.fc-split {
display: flex;
flex: 1;
min-height: 0;
}
/* v4.11.0 — left column holds both the canvas and the node-list panel.
* Flex-column so the canvas expands to fill remaining space and the
* nodelist sits below with a fixed max-height. */
.fc-left {
flex: 7;
min-width: 0;
display: flex;
flex-direction: column;
}
.fc-canvas-host {
flex: 1;
min-width: 0;
min-height: 200px;
position: relative;
background: #fafafa;
border-right: 1px solid var(--fc-border);
}
.fc-canvas-host svg.flowchart-canvas {
width: 100%;
height: 100%;
display: block;
cursor: default;
}
.fc-preview-host {
flex: 3;
min-width: 0;
display: flex;
flex-direction: column;
background: #fafafa;
}
.fc-preview-source {
flex: 1;
min-height: 120px;
margin: 0;
padding: 12px;
font-family: var(--mono-font, 'JetBrains Mono', monospace);
font-size: 12px;
overflow: auto;
white-space: pre;
border-bottom: 1px solid var(--fc-border);
background: #fff;
color: var(--fc-text);
}
.fc-preview-render {
flex: 1;
min-height: 120px;
padding: 12px;
overflow: auto;
background: #fff;
}
/* v4.9.6 — forced light surfaces inside the standalone window.
* Diverges from body theme to guarantee readability, mirroring the
* v4.9.5 fix in src/styles-sidebar.css for the (now-legacy) sidebar. */
.fc-canvas-host,
.fc-preview-host {
background: #fafafa !important;
color: #1f2328 !important;
}
.flowchart-node rect,
.flowchart-node polygon {
fill: #ffffff !important;
stroke: #1f2328 !important;
stroke-width: 1.25;
}
.flowchart-node text {
fill: #1f2328 !important;
}
.flowchart-edge {
stroke: #1f2328 !important;
}
/* v4.13.0 — visual polish. Adds a visible selection ring (was
previously only the .selected class without a distinct colour),
a hover tint, an enlarged resize-handle hit area, a smoother
modal dialog animation, and a proper opaque background behind
edge labels so they stay readable across curved connectors. */
.flowchart-node {
cursor: grab;
transition: filter 0.12s ease-out;
}
.flowchart-node:hover .flowchart-node-shape {
filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45));
}
.flowchart-node.selected .flowchart-node-shape {
stroke: #2563eb !important;
stroke-width: 2.25;
}
.flowchart-edge {
cursor: pointer;
}
.flowchart-edge:hover {
stroke: #2563eb !important;
}
.flowchart-edge.selected {
stroke: #2563eb !important;
stroke-width: 2.5;
}
/* Resize handle — larger invisible hit area + visible square. */
.flowchart-resize-handle {
cursor: nwse-resize;
}
.flowchart-resize-handle-hit {
fill: transparent;
stroke: none;
}
.flowchart-resize-handle-grip {
fill: #2563eb;
stroke: #1e40af;
stroke-width: 1;
}
/* Edge label background — keeps labels legible across curves by
giving them an opaque white pill behind the text. */
.flowchart-edge-label-bg {
fill: #ffffff;
stroke: #d1d5db;
stroke-width: 0.5;
}
/* Modal dialog — fade + scale-in entrance. */
.fc-modal {
animation: fc-modal-in 0.15s ease-out;
}
@keyframes fc-modal-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.fc-modal-overlay {
animation: fc-overlay-in 0.12s ease-out;
}
@keyframes fc-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* v4.13.0 — keyboard shortcuts modal + kbd tags. The modal
reuses the existing .fc-modal animation; the table lays
out Action / Shortcut columns, with <kbd> tags for the
key combos styled to look like physical keys. */
.fc-shortcuts-modal {
max-width: 460px;
width: 90vw;
}
.fc-shortcuts-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0 16px;
font-size: 0.9rem;
}
.fc-shortcuts-table th {
text-align: left;
font-weight: 600;
padding: 4px 0;
border-bottom: 1px solid var(--fc-border, #d1d5db);
color: #4b5563;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.fc-shortcuts-table td {
padding: 6px 8px;
border-bottom: 1px solid #f3f4f6;
}
.fc-shortcuts-table td:last-child {
text-align: right;
white-space: nowrap;
}
kbd {
display: inline-block;
padding: 1px 6px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
font-size: 0.78rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-bottom-width: 2px;
border-radius: 3px;
color: #1f2937;
margin: 0 1px;
}
/* v4.10.0 — preview-render info card explaining the layout */
.fc-preview-render-note {
font-size: 0.75rem;
color: #6e7681;
font-style: italic;
}
/* ============ v4.11.0 — node-list panel (button-driven UI) ============ */
.fc-nodelist {
border-top: 1px solid var(--fc-border);
padding: 16px 20px;
background: #fff;
max-height: 40vh;
overflow-y: auto;
}
.fc-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #57606a;
margin: 12px 0 8px;
font-weight: 600;
}
.fc-section-title:first-child {
margin-top: 0;
}
.fc-add-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.fc-history-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.fc-history-count {
margin-left: auto;
font-size: 11px;
color: var(--fc-border);
font-variant-numeric: tabular-nums;
}
.fc-ul {
list-style: none;
padding: 0;
margin: 0 0 12px;
}
.fc-ul li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--fc-border);
}
.fc-ul li .fc-node-id {
font-family: monospace;
color: #57606a;
min-width: 80px;
}
.fc-ul li input {
flex: 1;
padding: 4px 6px;
border: 1px solid var(--fc-border);
border-radius: 4px;
}
.fc-ul li select {
padding: 4px;
border: 1px solid var(--fc-border);
border-radius: 4px;
}
.fc-ul li button {
padding: 4px 10px;
border: 1px solid var(--fc-border);
background: #f6f8fa;
color: #1f2328;
border-radius: 4px;
cursor: pointer;
}
.fc-ul li button.fc-delete {
background: #cf222e;
color: #fff;
border-color: #cf222e;
}
.fc-connect-row {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
flex-wrap: wrap;
}
.fc-connect-row select {
padding: 4px;
border: 1px solid var(--fc-border);
border-radius: 4px;
min-width: 100px;
}
/* v4.12.0 — small helper text under section titles */
.fc-help {
font-size: 11px;
color: #57606a;
margin: 0 0 8px;
}
/* v4.12.0 — per-node fill color picker sits next to the kind <select>
* and label <input>. Native <input type="color"> gives a platform-native
* color dialog so the user can pick any color, including custom. */
.fc-ul li input[type='color'] {
flex: 0 0 auto;
padding: 0;
width: 32px;
height: 24px;
border: 1px solid var(--fc-border);
border-radius: 4px;
cursor: pointer;
background: #fff;
}
.fc-toolbar-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="fc-header">
<h1>Flowchart Generator</h1>
<span class="fc-hint"
>Use the panel below the canvas to add nodes and edges &middot; Click Insert at Cursor to
send to editor</span
>
</div>
<div class="fc-split">
<div class="fc-left">
<div class="fc-canvas-host" id="canvas-host"></div>
<!-- v4.13.0 — keyboard shortcuts overlay. Triggered by the
"Shortcuts (?)" toolbar button or the ? key. Hidden by
default; .open toggles visibility + the fade-in animation. -->
<div class="fc-modal-overlay" id="fc-shortcuts-overlay" hidden></div>
<div
class="fc-modal fc-shortcuts-modal"
id="fc-shortcuts-modal"
role="dialog"
aria-labelledby="fc-shortcuts-title"
hidden
>
<h2 id="fc-shortcuts-title">Keyboard Shortcuts</h2>
<table class="fc-shortcuts-table">
<thead>
<tr>
<th>Action</th>
<th>Shortcut</th>
</tr>
</thead>
<tbody>
<tr>
<td>Undo</td>
<td><kbd>Ctrl/⌘</kbd>+<kbd>Z</kbd></td>
</tr>
<tr>
<td>Redo</td>
<td><kbd>Ctrl/⌘</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></td>
</tr>
<tr>
<td>Copy selection</td>
<td><kbd>Ctrl/⌘</kbd>+<kbd>C</kbd></td>
</tr>
<tr>
<td>Paste</td>
<td><kbd>Ctrl/⌘</kbd>+<kbd>V</kbd></td>
</tr>
<tr>
<td>Duplicate</td>
<td><kbd>Ctrl/⌘</kbd>+<kbd>D</kbd></td>
</tr>
<tr>
<td>Delete selected</td>
<td><kbd>Delete</kbd> / <kbd>Backspace</kbd></td>
</tr>
<tr>
<td>Connect from a node</td>
<td><kbd>Alt</kbd>+drag</td>
</tr>
<tr>
<td>Pan view</td>
<td><kbd>Shift</kbd>+drag on background</td>
</tr>
<tr>
<td>Multi-select</td>
<td><kbd>Shift</kbd>+click / drag-rect</td>
</tr>
<tr>
<td>Zoom in / out</td>
<td><kbd>Ctrl</kbd>+wheel</td>
</tr>
<tr>
<td>Reset All</td>
<td>(toolbar button)</td>
</tr>
<tr>
<td>Show this overlay</td>
<td><kbd>?</kbd></td>
</tr>
</tbody>
</table>
<div class="fc-modal-actions">
<button class="fc-btn primary" id="fc-shortcuts-close">Close (Esc)</button>
</div>
</div>
<!--
v4.11.0 — button-driven UI. Every mutation (add/delete node,
add/delete edge, change label, change kind) goes through the
controls in this panel. The canvas is purely visual now.
v4.12.0 — Reorganised the panel so the Connect form is the
second section (right after Add Node) — user feedback said
the old layout buried it below the node/edge lists and they
couldn't find it. Also added a per-node color picker and a
Save to File button alongside Insert at Cursor.
-->
<div id="fc-nodelist" class="fc-nodelist">
<h3 class="fc-section-title">History</h3>
<div class="fc-history-row">
<button class="fc-btn" id="fc-btn-undo" title="Undo (Ctrl+Z)">↶ Undo</button>
<button class="fc-btn" id="fc-btn-redo" title="Redo (Ctrl+Shift+Z)">↷ Redo</button>
<span class="fc-history-count" id="fc-history-count" aria-live="polite">0</span>
</div>
<h3 class="fc-section-title">Add Node</h3>
<div class="fc-add-row">
<button class="fc-btn" data-add="process">+ Process</button>
<button class="fc-btn" data-add="decision">+ Decision</button>
<button class="fc-btn" data-add="terminator">+ Terminator</button>
<button class="fc-btn" data-add="subroutine">+ Subroutine</button>
<button class="fc-btn" data-add="document">+ Document</button>
</div>
<h3 class="fc-section-title">Add Connection</h3>
<p class="fc-help">Pick two nodes and click "+ Edge" to connect them.</p>
<div class="fc-connect-row">
<label for="fc-connect-from">From:</label>
<select id="fc-connect-from"></select>
<label for="fc-connect-to">To:</label>
<select id="fc-connect-to"></select>
<button class="fc-btn" id="fc-connect-btn">+ Edge</button>
<button class="fc-btn" id="fc-connect-cancel">Refresh</button>
</div>
<h3 class="fc-section-title">Nodes <span id="fc-node-count">0</span></h3>
<ul id="fc-nodelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Edges <span id="fc-edge-count">0</span></h3>
<ul id="fc-edgelist-ul" class="fc-ul"></ul>
<h3 class="fc-section-title">Export</h3>
<div class="fc-toolbar-row">
<button
class="fc-btn primary"
id="fc-btn-insert"
title="Insert Mermaid block at cursor in main editor"
>
Insert at Cursor
</button>
<button
class="fc-btn"
id="fc-btn-save"
title="Save Mermaid source to a .mmd / .md / .txt file"
>
Save to File
</button>
<button
class="fc-btn"
id="fc-btn-open"
title="Open a .mmd / .md file and load its flowchart"
>
Open from File
</button>
<button class="fc-btn" id="fc-btn-reset" title="Clear all nodes and edges">
Reset All
</button>
</div>
<h3 class="fc-section-title">Align (multi-select)</h3>
<div class="fc-toolbar-row">
<button
class="fc-btn"
id="fc-btn-select-all"
title="Select every node so the alignment buttons work"
>
Select All
</button>
</div>
<div class="fc-toolbar-row">
<button
class="fc-btn"
id="fc-btn-align-left"
title="Align selected nodes to the left edge"
>
Align Left
</button>
<button
class="fc-btn"
id="fc-btn-align-right"
title="Align selected nodes to the right edge"
>
Align Right
</button>
<button
class="fc-btn"
id="fc-btn-align-top"
title="Align selected nodes to the top edge"
>
Align Top
</button>
<button
class="fc-btn"
id="fc-btn-align-bottom"
title="Align selected nodes to the bottom edge"
>
Align Bottom
</button>
<button
class="fc-btn"
id="fc-btn-align-center-h"
title="Align selected nodes to a common horizontal center"
>
Center H
</button>
<button
class="fc-btn"
id="fc-btn-align-center-v"
title="Align selected nodes to a common vertical center"
>
Center V
</button>
<button
class="fc-btn"
id="fc-btn-distribute-h"
title="Distribute selected nodes evenly between leftmost and rightmost"
>
Distribute H
</button>
<button
class="fc-btn"
id="fc-btn-distribute-v"
title="Distribute selected nodes evenly between topmost and bottommost"
>
Distribute V
</button>
</div>
<h3 class="fc-section-title">Export Image</h3>
<div class="fc-toolbar-row">
<button class="fc-btn" id="fc-btn-export-svg" title="Export the canvas as a .svg file">
Export SVG
</button>
<button class="fc-btn" id="fc-btn-export-png" title="Export the canvas as a .png file">
Export PNG
</button>
<button class="fc-btn" id="fc-btn-export-jpg" title="Export the canvas as a .jpg file">
Export JPG
</button>
<button
class="fc-btn"
id="fc-btn-export-vsdx"
title="Export the canvas as an editable Visio .vsdx file"
>
Export Visio
</button>
<button
class="fc-btn"
id="fc-btn-help"
title="Show keyboard shortcuts (press ? any time)"
>
Shortcuts (?)
</button>
</div>
<span id="fc-status" aria-live="polite"></span>
</div>
</div>
<div class="fc-preview-host" id="preview-host">
<pre class="fc-preview-source" id="preview-source"></pre>
<div class="fc-preview-render" id="preview-render"></div>
</div>
</div>
<!--
v4.9.8 — bundle everything into one script. The four pure modules
(shapes / mermaid / store / canvas) plus the controller bootstrap
live in src/renderer/flowchart-bundle.js. No cross-file script-tag
ordering, no UMD wrappers, no `require()` — the bundle sets the
four window globals before the controller's bootstrap() runs.
v4.11.0 — the bundle's controller bootstrap wires up the
#fc-nodelist panel. The legacy #fc-selection-toolbar floating
toolbar is gone.
v4.12.0 — the bundle also wires the new #fc-btn-save button and
the per-node color picker inside the node list.
-->
<script src="renderer/flowchart-bundle.js"></script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
/**
* Pure alignment + distribution helpers for the flowchart editor (v4.13.0).
*
* Each function takes an array of node objects and returns a new array
* with the same nodes but their (x, y) adjusted per the operation.
* Pure module — no DOM, no globals, no store mutation — so it's
* unit-testable in isolation and the caller decides how to apply the
* result (one moveNode per node, or a future batch API).
*
* All operations are no-ops when given fewer than two nodes (nothing
* to align against). Distribute needs three or more to have a meaningful
* "even space" between extremes.
*
* Coordinates are SVG units. A node's height is the canvas default
* (60px) since the editor doesn't store height — only width.
*
* @module flowchart-align
*/
'use strict';
// v4.13.1 — DEFAULT_HEIGHT constant removed and inlined as 60 to avoid
// colliding with shapes.js's identically-named const when the build
// script concatenates both into the bundle.
function nodeWidth(node) {
return Number(node.width) > 0 ? Number(node.width) : 120;
}
function nodeHeight(_node) {
return 60;
}
function clone(node) {
return { ...node };
}
/** All nodes line up at the leftmost x. */
function alignLeft(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minX = Math.min(...nodes.map((n) => n.x));
return nodes.map((n) => Object.assign(clone(n), { x: minX }));
}
/** All nodes line up at the rightmost (x + width). */
function alignRight(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const maxRight = Math.max(...nodes.map((n) => n.x + nodeWidth(n)));
return nodes.map((n) => Object.assign(clone(n), { x: maxRight - nodeWidth(n) }));
}
/** All nodes line up at the topmost y. */
function alignTop(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const minY = Math.min(...nodes.map((n) => n.y));
return nodes.map((n) => Object.assign(clone(n), { y: minY }));
}
/** All nodes line up at the bottommost (y + height). */
function alignBottom(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const maxBottom = Math.max(...nodes.map((n) => n.y + nodeHeight(n)));
return nodes.map((n) => Object.assign(clone(n), { y: maxBottom - nodeHeight(n) }));
}
/** All nodes share the same horizontal center (mean of centers). */
function alignCenterHorizontal(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const centers = nodes.map((n) => n.x + nodeWidth(n) / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
return nodes.map((n) => Object.assign(clone(n), { x: avg - nodeWidth(n) / 2 }));
}
/** All nodes share the same vertical center (mean of centers). */
function alignCenterVertical(nodes) {
if (!Array.isArray(nodes) || nodes.length < 2) return nodes;
const centers = nodes.map((n) => n.y + nodeHeight(n) / 2);
const avg = centers.reduce((a, b) => a + b, 0) / centers.length;
return nodes.map((n) => Object.assign(clone(n), { y: avg - nodeHeight(n) / 2 }));
}
/**
* Distribute horizontally — equal gap between consecutive node left edges.
* The first and last nodes keep their positions; everything in between
* is spaced evenly.
*/
function distributeHorizontally(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = [...nodes].sort((a, b) => a.x - b.x);
const leftmost = sorted[0].x;
const rightmost = sorted[sorted.length - 1].x;
const gap = (rightmost - leftmost) / (sorted.length - 1);
return sorted.map((n, i) => Object.assign(clone(n), { x: leftmost + gap * i }));
}
/**
* Distribute vertically — equal gap between consecutive node top edges.
* Same first/last-anchored semantics as the horizontal version.
*/
function distributeVertically(nodes) {
if (!Array.isArray(nodes) || nodes.length < 3) return nodes;
const sorted = [...nodes].sort((a, b) => a.y - b.y);
const topmost = sorted[0].y;
const bottommost = sorted[sorted.length - 1].y;
const gap = (bottommost - topmost) / (sorted.length - 1);
return sorted.map((n, i) => Object.assign(clone(n), { y: topmost + gap * i }));
}
if (typeof module === 'object' && module.exports) {
module.exports = {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontally,
distributeVertically,
nodeWidth,
nodeHeight,
};
}
if (typeof window !== 'undefined') {
window.FlowchartAlign = {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontally,
distributeVertically,
nodeWidth,
nodeHeight,
};
}
+761
View File
@@ -0,0 +1,761 @@
/**
* SVG canvas for the flow chart editor.
*
* Owns an <svg class="flowchart-canvas"> mounted into the supplied container.
* Subscribes to the store; re-renders on every change. Listens for pointer
* events on nodes/edges to drive drag, label-edit, and context-menu actions.
*
* Hand-rolled SVG — no D3, no Konva. Hit-testing via `data-node-id` /
* `data-edge-id` attributes. Pure DOM module: no globals.
*
* @module flowchart-canvas
*/
'use strict';
// v4.9.6 — CommonJS sibling import when running under Node (the renderer.js
// sidebar panel still uses require()), browser global fallback when loaded as
// a <script> tag in the standalone window (no nodeIntegration).
const shapesModule =
(typeof window !== 'undefined' && window.FlowchartShapes) || require('./flowchart-shapes');
const viewportModule =
(typeof window !== 'undefined' && window.FlowchartViewport) || require('./flowchart-viewport');
const { DEFAULT_WIDTH, DEFAULT_HEIGHT, shapeSvg, SHAPE_KINDS } = shapesModule;
const {
zoomAt: vpZoomAt,
panBy: vpPanBy,
wheelFactor: vpWheelFactor,
snap: vpSnap,
} = viewportModule;
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === null || v === undefined) continue;
el.setAttribute(k, String(v));
}
return el;
}
function edgeStyle(kind) {
if (kind === 'dotted') return { 'stroke-dasharray': '4,4', 'stroke-width': 1 };
if (kind === 'thick') return { 'stroke-width': 3 };
return { 'stroke-width': 1 };
}
function nodeWidth(node) {
return Number(node.width) || DEFAULT_WIDTH;
}
function nodeCenter(node) {
return { x: node.x + nodeWidth(node) / 2, y: node.y + DEFAULT_HEIGHT / 2 };
}
/**
* Compute the point on a node's bounding-rectangle boundary in the direction
* (dx, dy). v4.13.0 — replaces the centre-to-centre lines that visually cut
* through nodes. All five supported shapes use the same bounding-rect
* approximation; for the diamond (decision) and parallelogram (document)
* this means the line lands a few pixels inside the visible shape, which
* is a fine visual trade for the simplicity.
*/
function boundaryPoint(node, dx, dy) {
const w = nodeWidth(node);
const cx = node.x + w / 2;
const cy = node.y + DEFAULT_HEIGHT / 2;
if (dx === 0 && dy === 0) return { x: cx, y: cy };
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const scaleX = absDx > 0 ? w / 2 / absDx : Infinity;
const scaleY = absDy > 0 ? DEFAULT_HEIGHT / 2 / absDy : Infinity;
const scale = Math.min(scaleX, scaleY);
return { x: cx + dx * scale, y: cy + dy * scale };
}
/**
* Compute edge endpoint coordinates so the line starts on the source
* shape's boundary and ends on the target's boundary, both pointing
* toward the other node.
*/
function edgeEndpoints(fromNode, toNode) {
const fc = nodeCenter(fromNode);
const tc = nodeCenter(toNode);
const dx = tc.x - fc.x;
const dy = tc.y - fc.y;
return {
from: boundaryPoint(fromNode, dx, dy),
to: boundaryPoint(toNode, -dx, -dy),
};
}
function createCanvas(container, store, opts = {}) {
const svg = svgEl('svg', {
class: 'flowchart-canvas',
width: '100%',
height: '100%',
viewBox: '0 0 1000 700',
role: 'img',
'aria-label': 'Flow chart canvas',
});
container.appendChild(svg);
// v4.13.0 — viewport wrapper. Edges/nodes live inside a single <g> whose
// transform reflects the current zoom + pan. SVG mouse coordinates are
// already in viewBox space (1000×700) so this stays in viewport units.
const view = viewportModule.reset();
const content = svgEl('g', {
class: 'flowchart-content',
transform: `translate(${view.tx},${view.ty}) scale(${view.scale})`,
});
svg.appendChild(content);
// Layer order: edges first (under nodes), then nodes.
const edgesLayer = svgEl('g', { class: 'flowchart-edges' });
const nodesLayer = svgEl('g', { class: 'flowchart-nodes' });
content.appendChild(edgesLayer);
content.appendChild(nodesLayer);
// v4.13.0 — snap-to-grid toggle. Off by default; controller can flip via
// setSnapEnabled(). When enabled, moveNode + addNode clamp coords to a
// 10-unit grid.
let snapEnabled = false;
const GRID_SIZE = 10;
function setSnapEnabled(enabled) {
snapEnabled = !!enabled;
}
let selectedNodeId = null;
let selectedEdgeId = null;
let unsubscribe = null;
let destroyed = false;
let panState = null; // v4.13.0 — drag-to-pan state on empty canvas
// v4.13.0 — multi-selection Set. Shift+click toggles membership;
// drag-rect on empty background replaces it with the intersected set.
// selectedNodeId remains the "primary" (last-clicked) for backward
// compat with the rest of the bundle; getMultiSelection returns the
// full set for the alignment / distribute buttons.
const selectedNodeIds = new Set();
let onSelectionChange = null;
function emitSelectionChange() {
if (typeof onSelectionChange === 'function') {
try {
onSelectionChange({
nodeId: selectedNodeId,
edgeId: selectedEdgeId,
nodeIds: Array.from(selectedNodeIds),
});
} catch {
// never let a caller bug kill the canvas
}
}
}
function setSelection({ nodeId = null, edgeId = null, additive = false } = {}) {
if (!additive) {
// Plain click — replace selection with the single node (or clear).
if (edgeId !== undefined) selectedEdgeId = edgeId;
if (nodeId !== undefined) {
selectedNodeId = nodeId;
selectedNodeIds.clear();
if (nodeId !== null) selectedNodeIds.add(nodeId);
}
} else {
// Shift+click — toggle membership of the clicked node. The edge
// selection is single-only (no multi-edge for now).
if (nodeId) {
if (selectedNodeIds.has(nodeId)) {
if (selectedNodeId === nodeId) {
// pick a different node as the new primary if any
const remaining = Array.from(selectedNodeIds).filter((id) => id !== nodeId);
selectedNodeId = remaining.length > 0 ? remaining[0] : null;
}
selectedNodeIds.delete(nodeId);
} else {
selectedNodeId = nodeId;
selectedNodeIds.add(nodeId);
}
selectedEdgeId = null;
}
}
applySelectionHighlight();
emitSelectionChange();
}
function getSelection() {
return { nodeId: selectedNodeId, edgeId: selectedEdgeId };
}
function getMultiSelection() {
return Array.from(selectedNodeIds);
}
function setOnSelectionChange(cb) {
onSelectionChange = typeof cb === 'function' ? cb : null;
}
function clearMultiSelection() {
selectedNodeIds.clear();
selectedNodeId = null;
selectedEdgeId = null;
applySelectionHighlight();
emitSelectionChange();
}
function setMultiSelection(nodeIds) {
selectedNodeIds.clear();
if (Array.isArray(nodeIds)) {
for (const id of nodeIds) {
if (typeof id === 'string' && id.length > 0) selectedNodeIds.add(id);
}
}
selectedNodeId = selectedNodeIds.size > 0 ? Array.from(selectedNodeIds)[0] : null;
selectedEdgeId = null;
applySelectionHighlight();
emitSelectionChange();
}
// v4.13.0 — drag-rect state. While the user drags on empty canvas,
// draw a translucent selection rectangle; on pointerup, replace the
// multi-selection with every node whose centre falls inside the rect.
let rectSelectState = null;
let rectOverlay = null;
// v4.13.0 — apply the current viewport to the content <g>'s transform.
function applyView() {
content.setAttribute('transform', `translate(${view.tx},${view.ty}) scale(${view.scale})`);
}
function render() {
if (destroyed) return;
const graph = store.getGraph();
edgesLayer.replaceChildren();
nodesLayer.replaceChildren();
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
for (const edge of graph.edges) {
const from = nodeById.get(edge.fromNodeId);
const to = nodeById.get(edge.toNodeId);
if (!from || !to) continue;
const { from: fp, to: tp } = edgeEndpoints(from, to);
const line = svgEl('line', {
x1: fp.x,
y1: fp.y,
x2: tp.x,
y2: tp.y,
stroke: 'currentColor',
'data-edge-id': edge.id,
...edgeStyle(edge.kind),
class: 'flowchart-edge' + (edge.id === selectedEdgeId ? ' selected' : ''),
});
edgesLayer.appendChild(line);
if (edge.label) {
// v4.13.0 — auto-size the label background based on the text
// length (no more fixed 40×16 box that overflows long labels).
const labelText = edge.label;
const labelWidth = Math.max(20, labelText.length * 6.5 + 8);
const labelHeight = 16;
const mx = (fp.x + tp.x) / 2;
const my = (fp.y + tp.y) / 2;
const bg = svgEl('rect', {
x: mx - labelWidth / 2,
y: my - labelHeight / 2,
width: labelWidth,
height: labelHeight,
rx: 3,
ry: 3,
fill: '#ffffff',
stroke: 'currentColor',
'stroke-opacity': '0.2',
'data-edge-label-bg': edge.id,
});
edgesLayer.appendChild(bg);
const t = svgEl('text', {
x: mx,
y: my + 4,
'text-anchor': 'middle',
'font-size': 11,
fill: 'currentColor',
'data-edge-label': edge.id,
});
t.textContent = labelText;
edgesLayer.appendChild(t);
}
}
for (const node of graph.nodes) {
const w = nodeWidth(node);
const g = svgEl('g', {
'data-node-id': node.id,
transform: `translate(${node.x},${node.y})`,
class: 'flowchart-node' + (node.id === selectedNodeId ? ' selected' : ''),
tabindex: '0',
'aria-label': `${node.kind}: ${node.label || '(no label)'}`,
});
g.innerHTML = shapeSvg(node.kind, 0, 0, w, DEFAULT_HEIGHT, node.color);
const text = svgEl('text', {
x: w / 2,
y: DEFAULT_HEIGHT / 2 + 4,
'text-anchor': 'middle',
'font-size': 13,
fill: 'currentColor',
'pointer-events': 'none',
});
text.textContent = node.label || ' ';
g.appendChild(text);
// v4.13.0 — bottom-right resize handle on the selected node. A
// single square handle is enough for v1; multi-handle (4 corners
// + 4 edges) is a follow-up. Width is the only mutable dimension.
// Two rects: a larger transparent hit area (24px) so the handle is
// easier to grab, plus a smaller visible grip (10px) styled via CSS.
if (node.id === selectedNodeId) {
const gripSize = 10;
const hitSize = 24;
const hit = svgEl('rect', {
x: w - hitSize / 2,
y: DEFAULT_HEIGHT - hitSize / 2,
width: hitSize,
height: hitSize,
class: 'flowchart-resize-handle flowchart-resize-handle-hit',
'data-resize-node': node.id,
});
const grip = svgEl('rect', {
x: w - gripSize / 2,
y: DEFAULT_HEIGHT - gripSize / 2,
width: gripSize,
height: gripSize,
rx: 2,
class: 'flowchart-resize-handle flowchart-resize-handle-grip',
});
g.appendChild(hit);
g.appendChild(grip);
}
nodesLayer.appendChild(g);
}
}
// Surgical selection highlight — toggles the `.selected` class on the
// existing SVG <g> / <line> elements without going through render() (which
// replaces all children and would detach the very element the user's
// pointer is still on, breaking pointermove/pointerup bubbling on the
// same node during a drag). Called from onPointerDown after the internal
// selectedNodeId/selectedEdgeId update. The existing
// .flowchart-node.selected / .flowchart-edge.selected CSS rules (see
// src/styles-sidebar.css) handle the visual highlight.
function applySelectionHighlight() {
if (destroyed) return;
const nodeEls = nodesLayer.querySelectorAll('g[data-node-id]');
nodeEls.forEach((g) => {
const id = g.getAttribute('data-node-id');
g.classList.toggle('selected', selectedNodeIds.has(id));
});
const edgeEls = edgesLayer.querySelectorAll('line[data-edge-id]');
edgeEls.forEach((l) => {
const id = l.getAttribute('data-edge-id');
l.classList.toggle('selected', id === selectedEdgeId);
});
}
// ----- pointer events -----
let dragState = null;
function getSvgPoint(clientX, clientY) {
const rect = svg.getBoundingClientRect();
// Naive linear mapping into the viewBox (works in jsdom and roughly in
// production for the bounded viewport; v2 can add proper screenCTM).
const vb = svg.viewBox.baseVal;
const scaleX = vb.width / rect.width;
const scaleY = vb.height / rect.height;
return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
}
function onPointerDown(ev) {
// v4.13.0 — resize handle on the selected node. Detected before the
// generic node drag so the bottom-right square doesn't accidentally
// start a move on the node.
const handleEl = ev.target.closest('[data-resize-node]');
if (handleEl) {
const nodeId = handleEl.getAttribute('data-resize-node');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
setSelection({ nodeId });
const startW = nodeWidth(node);
const startX = ev.clientX;
dragState = {
mode: 'resize',
nodeId,
startW,
startClientX: startX,
};
ev.preventDefault();
return;
}
const nodeG = ev.target.closest('g[data-node-id]');
if (nodeG) {
const nodeId = nodeG.getAttribute('data-node-id');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
// v4.13.0 — shift+click toggles the node in the multi-selection;
// plain click replaces the selection with just this node.
setSelection({ nodeId, additive: ev.shiftKey });
const start = getSvgPoint(ev.clientX, ev.clientY);
if (ev.altKey) {
// Alt+drag = create a new edge from this node to wherever the pointer
// is released. Show a preview line while dragging (v4.13.0 — was
// "visual feedback deferred to v2"). Movement does not move nodes.
dragState = { mode: 'connect', sourceNodeId: nodeId };
previewLine = svgEl('line', {
x1: boundaryPoint(node, 0, 0).x, // unused; updated in pointermove
y1: 0,
x2: start.x,
y2: start.y,
stroke: 'currentColor',
'stroke-dasharray': '4,4',
'stroke-width': 1,
'pointer-events': 'none',
class: 'flowchart-connect-preview',
});
edgesLayer.appendChild(previewLine);
updatePreviewLine(node, start);
} else {
dragState = {
mode: 'move',
nodeId,
startX: node.x,
startY: node.y,
pointerX: start.x,
pointerY: start.y,
};
}
// Notify the panel so its internal selection state (used by Delete /
// Backspace keyboard shortcuts) tracks the canvas selection.
if (typeof opts.onNodeClick === 'function') {
opts.onNodeClick(nodeId, ev);
}
ev.preventDefault();
return;
}
const edgeLine = ev.target.closest('line[data-edge-id]');
if (edgeLine) {
setSelection({ edgeId: edgeLine.getAttribute('data-edge-id') });
if (typeof opts.onEdgeClick === 'function') {
opts.onEdgeClick(selectedEdgeId, ev);
}
ev.preventDefault();
return;
}
// Click on empty canvas: middle-mouse OR Space-held = pan; otherwise
// start a drag-rect for multi-select. If the user releases without
// dragging, fall back to the existing "create node at click" behaviour
// so we don't accidentally lose the single-click affordance.
if (ev.target === svg || ev.target === nodesLayer || ev.target === edgesLayer) {
const isPan = ev.button === 1 || ev.shiftKey; // middle OR shift-drag
if (isPan) {
const p = getSvgPoint(ev.clientX, ev.clientY);
panState = { startX: p.x, startY: p.y, viewTx: view.tx, viewTy: view.ty };
setSelection({});
ev.preventDefault();
return;
}
const p = getSvgPoint(ev.clientX, ev.clientY);
// Start a drag-rect. We track the start coords; pointermove decides
// whether the user is dragging (rect-select) or just clicked (treat
// as onEmptyClick to create a node).
rectSelectState = {
startX: p.x,
startY: p.y,
additive: ev.shiftKey,
moved: false,
};
ev.preventDefault();
}
}
// v4.13.0 — render the drag-rect overlay. Drawn in screen coords
// (independent of viewport scale) so the visible thickness feels right
// even at low zoom.
function ensureRectOverlay() {
if (rectOverlay) return rectOverlay;
rectOverlay = svgEl('rect', {
x: 0,
y: 0,
width: 0,
height: 0,
fill: 'rgba(37, 99, 235, 0.12)',
stroke: '#2563eb',
'stroke-width': 1,
'stroke-dasharray': '4 3',
'pointer-events': 'none',
class: 'flowchart-rect-select',
});
svg.appendChild(rectOverlay);
return rectOverlay;
}
function updateRectOverlay(x, y, w, h) {
const overlay = ensureRectOverlay();
overlay.setAttribute('x', String(x));
overlay.setAttribute('y', String(y));
overlay.setAttribute('width', String(Math.max(0, w)));
overlay.setAttribute('height', String(Math.max(0, h)));
}
function clearRectOverlay() {
if (rectOverlay && rectOverlay.parentNode) {
rectOverlay.parentNode.removeChild(rectOverlay);
}
rectOverlay = null;
}
// v4.13.0 — connect-mode preview line. Re-anchored each move to the source
// node's boundary in the direction of the pointer.
let previewLine = null;
function updatePreviewLine(sourceNode, pointerSvg) {
if (!previewLine) return;
const fp = boundaryPoint(
sourceNode,
pointerSvg.x - sourceNode.x - DEFAULT_WIDTH / 2,
pointerSvg.y - sourceNode.y - DEFAULT_HEIGHT / 2
);
previewLine.setAttribute('x1', fp.x);
previewLine.setAttribute('y1', fp.y);
previewLine.setAttribute('x2', pointerSvg.x);
previewLine.setAttribute('y2', pointerSvg.y);
}
function removePreviewLine() {
if (previewLine && previewLine.parentNode) {
previewLine.parentNode.removeChild(previewLine);
}
previewLine = null;
}
function onPointerMove(ev) {
if (panState) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const dx = p.x - panState.startX;
const dy = p.y - panState.startY;
const next = vpPanBy(view, dx, dy);
view.tx = next.tx - panState.viewTx + view.tx; // accumulate deltas
view.ty = next.ty - panState.viewTy + view.ty;
// Reset view.tx/ty based on absolute computation from panState
view.tx = panState.viewTx + dx;
view.ty = panState.viewTy + dy;
applyView();
return;
}
if (rectSelectState) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x0 = Math.min(rectSelectState.startX, p.x);
const x1 = Math.max(rectSelectState.startX, p.x);
const y0 = Math.min(rectSelectState.startY, p.y);
const y1 = Math.max(rectSelectState.startY, p.y);
rectSelectState.moved = true;
updateRectOverlay(x0, y0, x1 - x0, y1 - y0);
return;
}
if (!dragState) return;
if (dragState.mode === 'resize') {
// Width delta in screen units, then convert to SVG via the
// viewport-aware viewBox-to-screen ratio.
const dxScreen = ev.clientX - dragState.startClientX;
const rect = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const scaleX = vb.width / rect.width;
const newW = Math.max(60, Math.min(600, dragState.startW + dxScreen * scaleX));
store.setNodeWidth(dragState.nodeId, newW);
return;
}
if (dragState.mode === 'move') {
const p = getSvgPoint(ev.clientX, ev.clientY);
const dx = p.x - dragState.pointerX;
const dy = p.y - dragState.pointerY;
let nx = dragState.startX + dx;
let ny = dragState.startY + dy;
if (snapEnabled) {
nx = vpSnap(nx, GRID_SIZE);
ny = vpSnap(ny, GRID_SIZE);
}
store.moveNode(dragState.nodeId, nx, ny);
} else if (dragState.mode === 'connect' && previewLine) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const sourceNode = store.getGraph().nodes.find((n) => n.id === dragState.sourceNodeId);
if (sourceNode) updatePreviewLine(sourceNode, p);
}
}
// v4.13.0 — Ctrl+wheel zooms around the cursor position.
function onWheel(ev) {
if (!ev.ctrlKey && !ev.metaKey) return;
ev.preventDefault();
const p = getSvgPoint(ev.clientX, ev.clientY);
const factor = vpWheelFactor(ev.deltaY);
const next = vpZoomAt(view, p.x, p.y, factor);
view.tx = next.tx;
view.ty = next.ty;
view.scale = next.scale;
applyView();
}
function onPointerUp(ev) {
if (panState) {
panState = null;
return;
}
if (dragState && dragState.mode === 'connect') {
const targetG = ev.target && ev.target.closest && ev.target.closest('g[data-node-id]');
if (targetG) {
const targetId = targetG.getAttribute('data-node-id');
if (targetId && targetId !== dragState.sourceNodeId) {
try {
store.connect(dragState.sourceNodeId, targetId, 'solid');
} catch {
// Connect throws on self-loop; canvas silently ignores.
}
}
}
}
// v4.13.0 — finalize drag-rect. If the rect is small (treat as a
// bare click) fall back to the existing onEmptyClick affordance so
// the user can still click-to-create a node. Otherwise intersect
// the rect against every node's centre and replace (or, on shift,
// merge into) the multi-selection.
if (rectSelectState) {
const p = getSvgPoint(ev.clientX, ev.clientY);
const x0 = Math.min(rectSelectState.startX, p.x);
const x1 = Math.max(rectSelectState.startX, p.x);
const y0 = Math.min(rectSelectState.startY, p.y);
const y1 = Math.max(rectSelectState.startY, p.y);
const width = x1 - x0;
const height = y1 - y0;
clearRectOverlay();
if (!rectSelectState.moved && width < 3 && height < 3) {
// Bare click on empty canvas — preserve the legacy "click to
// add a node" behaviour. First clear the selection so a new
// node isn't created while something else is selected.
if (!rectSelectState.additive) clearMultiSelection();
const finalX = snapEnabled
? vpSnap(Math.max(0, p.x - DEFAULT_WIDTH / 2), GRID_SIZE)
: Math.max(0, p.x - DEFAULT_WIDTH / 2);
const finalY = snapEnabled
? vpSnap(Math.max(0, p.y - DEFAULT_HEIGHT / 2), GRID_SIZE)
: Math.max(0, p.y - DEFAULT_HEIGHT / 2);
if (typeof opts.onEmptyClick === 'function') {
opts.onEmptyClick(finalX, finalY, ev);
}
} else {
const hits = [];
const nodes = store.getGraph().nodes;
for (const node of nodes) {
const cx = node.x + nodeWidth(node) / 2;
const cy = node.y + DEFAULT_HEIGHT / 2;
if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) hits.push(node.id);
}
if (rectSelectState.additive) {
// shift-drag rect — add hits to the existing selection
const next = new Set(selectedNodeIds);
for (const id of hits) next.add(id);
setMultiSelection(Array.from(next));
} else {
// plain drag-rect — replace selection with hits (or clear)
setMultiSelection(hits);
}
}
rectSelectState = null;
}
removePreviewLine();
dragState = null;
}
function onDblClick(ev) {
const nodeG = ev.target.closest('g[data-node-id]');
if (!nodeG) return;
const nodeId = nodeG.getAttribute('data-node-id');
const node = store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
// Inline-edit overlay: foreignObject-free — just use a positioned HTML
// <input> overlaid on top of the node, in the canvas's parent.
const input = document.createElement('input');
input.type = 'text';
input.value = node.label;
input.className = 'flowchart-label-input';
const rect = nodeG.getBoundingClientRect();
input.style.position = 'fixed';
input.style.left = `${rect.left}px`;
input.style.top = `${rect.top}px`;
input.style.width = `${rect.width}px`;
input.style.height = `${rect.height}px`;
container.appendChild(input);
input.focus();
input.select();
const finish = (commit) => {
if (commit) store.setNodeLabel(nodeId, input.value);
input.remove();
};
input.addEventListener('blur', () => finish(true));
input.addEventListener('keydown', (kev) => {
if (kev.key === 'Enter') finish(true);
else if (kev.key === 'Escape') finish(false);
});
ev.preventDefault();
}
function onContextMenu(ev) {
const nodeG = ev.target.closest('g[data-node-id]');
if (nodeG) {
const nodeId = nodeG.getAttribute('data-node-id');
ev.preventDefault();
if (typeof opts.onShapeMenu === 'function') opts.onShapeMenu(nodeId, ev);
}
}
svg.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
svg.addEventListener('dblclick', onDblClick);
svg.addEventListener('contextmenu', onContextMenu);
svg.addEventListener('wheel', onWheel, { passive: false });
unsubscribe = store.subscribe(render);
render();
function destroy() {
if (destroyed) return;
destroyed = true;
if (typeof unsubscribe === 'function') unsubscribe();
removePreviewLine();
svg.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
svg.removeEventListener('dblclick', onDblClick);
svg.removeEventListener('contextmenu', onContextMenu);
svg.removeEventListener('wheel', onWheel);
svg.remove();
}
return {
destroy,
getSvg: () => svg,
getSelection,
getMultiSelection,
setMultiSelection,
clearMultiSelection,
setOnSelectionChange,
setSnapEnabled,
};
}
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
// (window.FlowchartCanvas) so the standalone window's controller can load
// this module via <script> tag without nodeIntegration.
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exported;
} else {
root.FlowchartCanvas = exported;
}
// v4.9.7 — also expose as window global when running in Electron renderer
// (nodeIntegration:true makes `module` truthy so the else branch above never
// runs; the controller still expects window.FlowchartCanvas).
if (typeof window !== 'undefined') {
window.FlowchartCanvas = exported;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
return { createCanvas, SHAPE_KINDS };
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Pure clipboard serialiser for the flowchart editor (v4.13.0).
*
* Serialises the selected node + its edges to a JSON envelope that
* survives a paste-into-new-graph round trip. Uses a fresh id-mangling
* pass on paste so duplicates don't collide with the originals.
*
* Pure module — no DOM, no globals.
*
* @module flowchart-clipboard
*/
const CLIPBOARD_VERSION = 1;
/**
* @param {object} graph current graph from store
* @param {{nodeId?:string, edgeId?:string}} selection canvas selection
* @returns {object|null} clipboard payload or null when nothing useful
* is selected
*/
function copySelection(graph, selection) {
if (!graph || !selection) return null;
if (selection.nodeId) {
const node = graph.nodes.find((n) => n.id === selection.nodeId);
if (!node) return null;
const connectedEdges = graph.edges.filter(
(e) => e.fromNodeId === node.id || e.toNodeId === node.id
);
return {
version: CLIPBOARD_VERSION,
kind: 'node-with-edges',
node,
edges: connectedEdges,
};
}
if (selection.edgeId) {
const edge = graph.edges.find((e) => e.id === selection.edgeId);
if (!edge) return null;
return { version: CLIPBOARD_VERSION, kind: 'edge', edge };
}
return null;
}
/**
* Apply a clipboard payload to a graph + selection. Returns the new
* node(s) created so the controller can move the selection.
*
* @param {object} payload from copySelection()
* @param {object} graph current graph
* @param {object} store store with addNode / connect APIs
* @param {{offsetX?:number, offsetY?:number}} [opts]
* @returns {Array<{id:string}>} ids of newly created nodes (empty for edge-only paste)
*/
function pasteSelection(payload, graph, store, opts = {}) {
if (!payload) return [];
const dx = opts.offsetX || 24;
const dy = opts.offsetY || 24;
if (payload.kind === 'node-with-edges' && payload.node) {
const oldNode = payload.node;
const newNode = store.addNode({
kind: oldNode.kind,
x: (oldNode.x || 0) + dx,
y: (oldNode.y || 0) + dy,
label: oldNode.label,
color: oldNode.color,
});
// Re-attach edges: each connected edge maps its endpoint to either
// the new node (when it was the original) or to the existing neighbour.
const idRemap = new Map([[oldNode.id, newNode.id]]);
const edges = payload.edges || [];
for (const e of edges) {
const from = idRemap.get(e.fromNodeId) || e.fromNodeId;
const to = idRemap.get(e.toNodeId) || e.toNodeId;
// Both endpoints must still exist in the current graph
if (graph.nodes.find((n) => n.id === from) && graph.nodes.find((n) => n.id === to)) {
store.connect(from, to, e.kind);
}
}
return [{ id: newNode.id }];
}
if (payload.kind === 'edge' && payload.edge) {
const e = payload.edge;
if (
graph.nodes.find((n) => n.id === e.fromNodeId) &&
graph.nodes.find((n) => n.id === e.toNodeId)
) {
store.connect(e.fromNodeId, e.toNodeId, e.kind);
}
return [];
}
return [];
}
module.exports = { copySelection, pasteSelection, CLIPBOARD_VERSION };
+209
View File
@@ -0,0 +1,209 @@
/**
* Pure parser: Mermaid `flowchart TD` source → graph.
*
* Inverse of flowchart-mermaid.js's toMermaid(). Recognises the 5
* node shapes (process / decision / subroutine / terminator / document)
* and the 3 edge kinds (solid / dotted / thick).
*
* Pure module — no DOM, no globals. Defensive: unrecognised lines are
* skipped, not thrown on, so partial / hand-edited source still loads
* whatever it can.
*
* Inverse of flowchart-mermaid.js toMermaid():
* id assignment: A..Z, AA..ZZ, ...
* escape: #quot; → ", \\n → \n
*
* @module flowchart-mermaid-parse
*/
// Order matters: longest prefix first so `[[label]]` doesn't get
// matched as `process` `[label]` first.
const SHAPE_FROM_SYNTAX = [
{ prefix: '[[', suffix: ']]', kind: 'subroutine' }, // [[label]]
{ prefix: '([', suffix: '])', kind: 'terminator' }, // ([label])
{ prefix: '[/', suffix: '/]', kind: 'document' }, // [/label/]
{ prefix: '{', suffix: '}', kind: 'decision' }, // {label}
{ prefix: '[', suffix: ']', kind: 'process' }, // [label]
];
const EDGE_FROM_ARROW = {
'-->': 'solid',
'-.->': 'dotted',
'==>': 'thick',
};
function unescapeLabel(label) {
return String(label || '')
.replace(/#quot;/g, '"')
.replace(/\\n/g, '\n');
}
/**
* Recognise a node declaration like `A[Step 1]`, `B{Valid?}`,
* `C([Start])`, etc. The id is the leading run of identifier chars.
*
* Returns the matched id + label + kind, or null on no match.
*/
function parseNodeDeclaration(line) {
const idBody = splitId(line);
if (!idBody) return null;
const brackets = idBody.body;
for (const shape of SHAPE_FROM_SYNTAX) {
if (brackets.startsWith(shape.prefix) && brackets.endsWith(shape.suffix)) {
const label = brackets.slice(shape.prefix.length, brackets.length - shape.suffix.length);
return { id: idBody.id, label: unescapeLabel(label), kind: shape.kind };
}
}
return null;
}
/**
* Split a line into id, body. The id is the leading run of [A-Za-z0-9_].
* Everything after is the body.
*/
function splitId(line) {
const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*(.*)$/.exec(line);
if (!m) return null;
return { id: m[1], body: m[2] };
}
/**
* Parse a single Mermaid edge with optional label `|label|`.
* Handles `-->`, `-.->`, `==>`.
* Returns { fromId, toId, kind, label } or null.
*/
function parseEdgeLine(body) {
// Find an edge arrow. The label, when present, is `|label|` between
// the source id and the arrow OR between the arrow and the target id.
// We accept both orderings for robustness.
const labelRe = /\|([^|]*)\|/;
// Find arrow first
let arrow = null;
let arrowIdx = -1;
for (const candidate of Object.keys(EDGE_FROM_ARROW)) {
const idx = body.indexOf(candidate);
if (idx !== -1 && (arrowIdx === -1 || idx < arrowIdx)) {
arrow = candidate;
arrowIdx = idx;
}
}
if (!arrow) return null;
const before = body.slice(0, arrowIdx).trim();
const after = body.slice(arrowIdx + arrow.length).trim();
// Source id: strip the source id + optional label
const beforeLabel = labelRe.exec(before);
const beforeLabelStr = beforeLabel ? beforeLabel[0] : null;
const sourceStr = beforeLabelStr ? before.replace(beforeLabelStr, '').trim() : before;
const afterLabel = labelRe.exec(after);
const afterLabelStr = afterLabel ? afterLabel[0] : null;
const targetStr = afterLabelStr ? after.replace(afterLabelStr, '').trim() : after;
if (!sourceStr || !targetStr) return null;
// Find the label (prefer the one nearer to the arrow)
let label = null;
if (afterLabelStr) {
label = unescapeLabel(afterLabel[1]);
} else if (beforeLabelStr) {
label = unescapeLabel(beforeLabel[1]);
}
return {
fromNodeId: sourceStr,
toNodeId: targetStr,
kind: EDGE_FROM_ARROW[arrow],
label: label || '',
};
}
/**
* Parse a full Mermaid source string into a graph object.
*
* @param {string} source
* @returns {{nodes:Array, edges:Array}}
*/
function fromMermaid(source) {
const nodes = [];
const edges = [];
const nodeByMermaidId = new Map(); // mermaid id → generated store id
let layoutCounter = 0;
if (typeof source !== 'string') {
return { nodes, edges };
}
const lines = source
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('%%') /* mermaid comment */);
for (const line of lines) {
// Skip the header
if (/^flowchart\s+(TD|LR|BT|RL)/i.test(line)) continue;
// Edge line?
const edge = parseEdgeLine(line);
if (edge) {
// Mermaid-side id → store-side id (assign on demand)
const fromId = ensureNodeId(edge.fromNodeId, nodeByMermaidId, nodes, () =>
makeAutoNode(nextLayoutPos(layoutCounter++))
);
const toId = ensureNodeId(edge.toNodeId, nodeByMermaidId, nodes, () =>
makeAutoNode(nextLayoutPos(layoutCounter++))
);
edges.push({
id: 'e_' + edges.length + '_' + Date.now().toString(36),
fromNodeId: fromId,
toNodeId: toId,
kind: edge.kind,
label: edge.label,
});
continue;
}
// Node declaration?
const parsed = parseNodeDeclaration(line);
if (parsed) {
const storeId = 'n_' + parsed.id;
nodeByMermaidId.set(parsed.id, storeId);
if (!nodes.find((n) => n.id === storeId)) {
nodes.push({
id: storeId,
kind: parsed.kind,
x: 40 + (nodes.length % 5) * 160,
y: 40 + Math.floor(nodes.length / 5) * 100,
label: parsed.label,
});
}
}
}
return { nodes, edges };
}
function ensureNodeId(mermaidId, map, nodes, createFn) {
if (map.has(mermaidId)) return map.get(mermaidId);
const node = createFn();
nodes.push(node);
map.set(mermaidId, node.id);
return node.id;
}
function makeAutoNode(pos) {
return {
id: 'n_auto_' + Math.random().toString(36).slice(2, 8),
kind: 'process',
x: pos.x,
y: pos.y,
label: '',
};
}
function nextLayoutPos(n) {
return { x: 40 + (n % 5) * 160, y: 40 + Math.floor(n / 5) * 100 };
}
module.exports = { fromMermaid, parseEdgeLine, parseNodeDeclaration, EDGE_FROM_ARROW };
+111
View File
@@ -0,0 +1,111 @@
/**
* Pure translator: graph → Mermaid `flowchart TD` source.
*
* Output format:
* flowchart TD
* A[Step 1]
* B{Valid?}
* C([Start])
* D[[Do thing]]
* E[/Report/]
* A --> B
* B -->|yes| C
*
* IDs are 1-3 chars (A..Z, AA..ZZ, …). Labels are escaped: double quotes
* become `#quot;`, newlines become literal `\n` (Mermaid's escape).
*
* @module flowchart-mermaid
*/
'use strict';
const SHAPE_SYNTAX = {
process: (id, label) => `${id}[${label}]`,
decision: (id, label) => `${id}{${label}}`,
terminator: (id, label) => `${id}([${label}])`,
subroutine: (id, label) => `${id}[[${label}]]`,
document: (id, label) => `${id}[/${label}/]`,
};
const EDGE_ARROW = {
solid: '-->',
dotted: '-.->',
thick: '==>',
};
function escapeLabel(label) {
return String(label || '')
.replace(/"/g, '#quot;')
.replace(/\r?\n/g, '\\n');
}
function assignIds(nodes) {
// A, B, C, …, Z, AA, AB, … (matches Mermaid's preferred short ids).
const map = new Map();
let n = 0;
for (const node of nodes) {
let id;
if (n < 26) id = String.fromCharCode(65 + n);
else {
const first = Math.floor(n / 26) - 1;
const second = n % 26;
id = String.fromCharCode(65 + first) + String.fromCharCode(65 + second);
}
map.set(node.id, id);
n += 1;
}
return map;
}
function nodeDeclaration(node) {
const fn = SHAPE_SYNTAX[node.kind];
if (!fn) throw new Error(`flowchart-mermaid: unknown node kind "${node.kind}"`);
return fn(node.id, escapeLabel(node.label));
}
function edgeDeclaration(edge, fromId, toId) {
const arrow = EDGE_ARROW[edge.kind];
if (!arrow) throw new Error(`flowchart-mermaid: unknown edge kind "${edge.kind}"`);
if (edge.label && edge.label.length > 0) {
return `${fromId} ${arrow}|${escapeLabel(edge.label)}| ${toId}`;
}
return `${fromId} ${arrow} ${toId}`;
}
function toMermaid(graph) {
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
throw new Error('flowchart-mermaid: graph must have nodes[] and edges[]');
}
const ids = assignIds(graph.nodes);
const lines = ['flowchart TD'];
for (const node of graph.nodes) {
lines.push(nodeDeclaration({ ...node, id: ids.get(node.id) }));
}
for (const edge of graph.edges) {
const fromId = ids.get(edge.fromNodeId);
const toId = ids.get(edge.toNodeId);
if (!fromId || !toId) continue; // skip dangling edges (defensive)
lines.push(edgeDeclaration(edge, fromId, toId));
}
return lines.join('\n');
}
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
// (window.FlowchartMermaid) so the standalone window's controller can load
// this module via <script> tag without nodeIntegration.
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exported;
} else {
root.FlowchartMermaid = exported;
}
// v4.9.7 — also expose as window global when running in Electron renderer
// (nodeIntegration:true makes `module` truthy so the else branch above never
// runs; the controller still expects window.FlowchartMermaid).
if (typeof window !== 'undefined') {
window.FlowchartMermaid = exported;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
return { toMermaid, escapeLabel, nodeDeclaration, edgeDeclaration };
});
+94
View File
@@ -0,0 +1,94 @@
/**
* SVG shape templates for the 5 supported Mermaid flowchart node kinds.
* Each `shapeSvg` returns ONE SVG element string — the canvas wraps it in a
* <g data-node-id="…"> alongside a <text> label.
*
* v4.12.0 — Added an optional `color` (6th) argument so callers can set a
* per-node fill color. Falls back to `#ffffff` when omitted so callers that
* don't care about color (the existing tests, the sidebar panel) keep
* working unchanged.
*
* Pure module: no DOM, no globals, no side effects.
*
* @module flowchart-shapes
*/
'use strict';
const SHAPE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
const DEFAULT_WIDTH = 140;
const DEFAULT_HEIGHT = 60;
const LABEL_PADDING_X = 16;
const LABEL_PADDING_Y = 12;
const DEFAULT_FILL = '#ffffff';
function shapeSvg(kind, x, y, width, height, color) {
if (!SHAPE_KINDS.includes(kind)) {
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
}
const fill = typeof color === 'string' && color.length > 0 ? color : DEFAULT_FILL;
switch (kind) {
case 'process':
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />`;
case 'terminator':
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" ry="${height / 2}" fill="${fill}" />`;
case 'subroutine': {
const inset = 4;
return (
`<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="4" ry="4" fill="${fill}" />` +
`<rect x="${x + inset}" y="${y + inset}" width="${width - 2 * inset}" height="${height - 2 * inset}" rx="4" ry="4" fill="${fill}" />`
);
}
case 'decision': {
const cx = x + width / 2;
const cy = y + height / 2;
const left = `${x},${cy}`;
const top = `${cx},${y}`;
const right = `${x + width},${cy}`;
const bottom = `${cx},${y + height}`;
return `<polygon points="${left} ${top} ${right} ${bottom}" fill="${fill}" />`;
}
case 'document': {
// Parallelogram: top-right and bottom-right indented by ~20% of height.
const skew = Math.max(10, Math.round(height * 0.25));
const tl = `${x + skew},${y}`;
const tr = `${x + width},${y}`;
const br = `${x + width - skew},${y + height}`;
const bl = `${x},${y + height}`;
return `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" />`;
}
default:
throw new Error(`flowchart-shapes: unknown shape kind "${kind}"`);
}
}
// v4.9.6 UMD wrapper — exposes the same surface as a CommonJS module
// (used by src/sidebar/flowchart-panel.js via require()) AND as a browser
// global (used by src/renderer/flowchart-controller.js via <script> tag).
// The standalone BrowserWindow runs with contextIsolation:true +
// nodeIntegration:false, so the renderer cannot require() these modules;
// loading them as <script> tags in src/flowchart-generator.html attaches
// them to window.FlowchartShapes.
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exported;
} else {
root.FlowchartShapes = exported;
}
// v4.9.7 — also expose as window global when running in Electron renderer
// (nodeIntegration:true makes `module` truthy so the else branch above never
// runs; the controller still expects window.FlowchartShapes).
if (typeof window !== 'undefined') {
window.FlowchartShapes = exported;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
return {
shapeSvg,
SHAPE_KINDS,
DEFAULT_WIDTH,
DEFAULT_HEIGHT,
LABEL_PADDING_X,
LABEL_PADDING_Y,
};
});
+332
View File
@@ -0,0 +1,332 @@
/**
* Pure graph store for the flow chart editor.
*
* Graph = { nodes: Node[], edges: Edge[] }
* Node: { id, kind, x, y, label, color? } (color: CSS hex string, defaults to #ffffff)
* Edge: { id, fromNodeId, toNodeId, kind: 'solid'|'dotted'|'thick', label? }
*
* v4.12.0 — Nodes carry an optional `color` field (CSS hex string).
* `setNodeColor(id, color)` mutates it; `serialize`/`deserialize` round-trip it.
*
* IO is injected for unit tests + persistence:
* { persistencePath, readFile, writeFile, now }
*
* @module flowchart-store
*/
const NODE_KINDS = ['process', 'decision', 'terminator', 'subroutine', 'document'];
const EDGE_KINDS = ['solid', 'dotted', 'thick'];
const UNDO_LIMIT = 50;
const DEFAULT_NODE_COLOR = '#ffffff';
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
function newId(prefix) {
// 12 hex chars; monotonic enough for in-memory use.
return `${prefix}_${Math.random().toString(16).slice(2, 10)}${Date.now().toString(16).slice(-4)}`;
}
function isValidNode(node) {
return (
node &&
typeof node.id === 'string' &&
NODE_KINDS.includes(node.kind) &&
Number.isFinite(node.x) &&
Number.isFinite(node.y) &&
typeof node.label === 'string'
);
}
/**
* Normalise a node's `color` field. Accepts a CSS hex string (with or without
* the leading `#`), rejects anything else by falling back to the default.
* Returns `undefined` when the input is falsy so callers can use the spread
* operator (`{ ...node, ...normalizeColor(node.color) }`) without overwriting
* existing fields with `undefined`.
*/
function normalizeColor(color) {
if (typeof color !== 'string' || color.length === 0) return { color: DEFAULT_NODE_COLOR };
const trimmed = color.trim();
// Hex: #rgb / #rrggbb (case-insensitive). Anything else falls back to default.
const hex = /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(trimmed);
if (!hex) return { color: DEFAULT_NODE_COLOR };
return { color: trimmed.startsWith('#') ? trimmed : `#${trimmed}` };
}
function isValidEdge(edge) {
return (
edge &&
typeof edge.id === 'string' &&
typeof edge.fromNodeId === 'string' &&
typeof edge.toNodeId === 'string' &&
EDGE_KINDS.includes(edge.kind)
);
}
/**
* @param {object} io
* @param {string} io.persistencePath Absolute path for auto-save JSON.
* @param {(path:string) => Promise<string|null>} io.readFile
* @param {(path:string, content:string) => Promise<void>} io.writeFile
* @param {() => number} io.now
*/
function create(io) {
if (!io || typeof io !== 'object') {
throw new Error('flowchart-store: io bundle is required');
}
let graph = { nodes: [], edges: [] };
const undoStack = [];
const redoStack = [];
const listeners = new Set();
function emit() {
for (const fn of listeners) {
try {
fn(getGraph());
} catch {
// Don't let a subscriber crash the store.
}
}
}
function snapshot() {
undoStack.push(clone(graph));
if (undoStack.length > UNDO_LIMIT) undoStack.shift();
redoStack.length = 0;
}
function getGraph() {
return clone(graph);
}
function addNode({ kind, x, y, label = '', color }) {
if (!NODE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
}
snapshot();
const node = {
id: newId('n'),
kind,
x,
y,
label,
...normalizeColor(color),
};
graph.nodes.push(node);
emit();
return node;
}
function findNodeIndex(id) {
return graph.nodes.findIndex((n) => n.id === id);
}
function findEdgeIndex(id) {
return graph.edges.findIndex((e) => e.id === id);
}
function moveNode(id, x, y) {
const idx = findNodeIndex(id);
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
snapshot();
graph.nodes[idx] = { ...graph.nodes[idx], x, y };
emit();
}
function setNodeLabel(id, label) {
const idx = findNodeIndex(id);
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
snapshot();
graph.nodes[idx] = { ...graph.nodes[idx], label };
emit();
}
function setNodeKind(id, kind) {
if (!NODE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown node kind "${kind}"`);
}
const idx = findNodeIndex(id);
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
snapshot();
graph.nodes[idx] = { ...graph.nodes[idx], kind };
emit();
}
function setNodeColor(id, color) {
const idx = findNodeIndex(id);
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
snapshot();
graph.nodes[idx] = { ...graph.nodes[idx], ...normalizeColor(color) };
emit();
}
/**
* Resize a node by adjusting its width (v4.13.0). Height is derived from
* the shape, so width is the only mutable dimension. Min 60 / max 600.
*/
function setNodeWidth(id, width) {
const idx = findNodeIndex(id);
if (idx === -1) throw new Error(`flowchart-store: unknown node id "${id}"`);
const w = Math.max(60, Math.min(600, Number(width) || 60));
snapshot();
graph.nodes[idx] = { ...graph.nodes[idx], width: w };
emit();
}
function removeNode(id) {
const idx = findNodeIndex(id);
if (idx === -1) return;
snapshot();
graph.nodes.splice(idx, 1);
graph.edges = graph.edges.filter((e) => e.fromNodeId !== id && e.toNodeId !== id);
emit();
}
function connect(fromNodeId, toNodeId, kind = 'solid') {
if (fromNodeId === toNodeId) {
throw new TypeError('flowchart-store: cannot connect a node to itself');
}
if (!EDGE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown edge kind "${kind}"`);
}
snapshot();
const edge = { id: newId('e'), fromNodeId, toNodeId, kind };
graph.edges.push(edge);
emit();
return edge;
}
function disconnect(edgeId) {
const idx = findEdgeIndex(edgeId);
if (idx === -1) return;
snapshot();
graph.edges.splice(idx, 1);
emit();
}
function setEdgeKind(edgeId, kind) {
if (!EDGE_KINDS.includes(kind)) {
throw new Error(`flowchart-store: unknown edge kind "${kind}"`);
}
const idx = findEdgeIndex(edgeId);
if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`);
snapshot();
graph.edges[idx] = { ...graph.edges[idx], kind };
emit();
}
function setEdgeLabel(edgeId, label) {
const idx = findEdgeIndex(edgeId);
if (idx === -1) throw new Error(`flowchart-store: unknown edge id "${edgeId}"`);
snapshot();
graph.edges[idx] = { ...graph.edges[idx], label };
emit();
}
function undo() {
const prior = undoStack.pop();
if (!prior) return;
redoStack.push(clone(graph));
graph = prior;
emit();
}
function redo() {
const next = redoStack.pop();
if (!next) return;
undoStack.push(clone(graph));
graph = next;
emit();
}
function canUndo() {
return undoStack.length > 0;
}
function canRedo() {
return redoStack.length > 0;
}
function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
function serialize() {
return JSON.stringify(graph);
}
function deserialize(json) {
let parsed;
try {
parsed = typeof json === 'object' ? json : JSON.parse(json);
} catch {
graph = { nodes: [], edges: [] };
return;
}
const rawNodes = Array.isArray(parsed.nodes) ? parsed.nodes.filter(isValidNode) : [];
// v4.12.0 — normalise the optional `color` field on every node so the
// rehydrated graph has a guaranteed valid color (#ffffff by default).
const nodes = rawNodes.map((n) => ({ ...n, ...normalizeColor(n.color) }));
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = Array.isArray(parsed.edges)
? parsed.edges.filter(
(e) => isValidEdge(e) && nodeIds.has(e.fromNodeId) && nodeIds.has(e.toNodeId)
)
: [];
graph = { nodes, edges };
undoStack.length = 0;
redoStack.length = 0;
emit();
}
function toJSON() {
return clone(graph);
}
return {
getGraph,
addNode,
moveNode,
setNodeLabel,
setNodeKind,
setNodeColor,
setNodeWidth,
removeNode,
connect,
disconnect,
setEdgeKind,
setEdgeLabel,
undo,
redo,
canUndo,
canRedo,
subscribe,
serialize,
deserialize,
toJSON,
};
}
// v4.9.6 UMD wrapper — same CommonJS export shape + browser global
// (window.FlowchartStore) so the standalone window's controller can load
// this module via <script> tag without nodeIntegration.
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) {
module.exports = exported;
} else {
root.FlowchartStore = exported;
}
// v4.9.7 — also expose as window global when running in Electron renderer
// (nodeIntegration:true makes `module` truthy so the else branch above never
// runs; the controller still expects window.FlowchartStore).
if (typeof window !== 'undefined') {
window.FlowchartStore = exported;
}
})(typeof window !== 'undefined' ? window : globalThis, function () {
return { create, NODE_KINDS, EDGE_KINDS };
});
+71
View File
@@ -0,0 +1,71 @@
/**
* Pure viewport math for the flowchart canvas (v4.13.0).
*
* The SVG canvas keeps a fixed viewBox (1000×700). All content lives
* inside a single <g transform="translate(tx,ty) scale(scale)"> so we
* can zoom and pan without re-rendering.
*
* Pure module — no DOM, no globals — so the math is unit-testable.
*
* @module flowchart-viewport
*/
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
const SCALE_STEP = 1.1; // multiplicative per Ctrl+wheel notch
/**
* Zoom centred on a point in *screen* coordinates (the cursor position
* inside the SVG viewport). The point under the cursor stays fixed on
* screen as the scale changes.
*/
function zoomAt(view, screenX, screenY, factor) {
const newScale = clamp(view.scale * factor, MIN_SCALE, MAX_SCALE);
const actualFactor = newScale / view.scale;
// Derivation: world under cursor is ((sx-tx)/scale, ...). After zoom,
// we want the same world to render at the same screen position.
// Solving for tx' = sx - (sx - tx) * actualFactor.
return {
scale: newScale,
tx: screenX - (screenX - view.tx) * actualFactor,
ty: screenY - (screenY - view.ty) * actualFactor,
};
}
function panBy(view, dx, dy) {
return { scale: view.scale, tx: view.tx + dx, ty: view.ty + dy };
}
function reset() {
return { tx: 0, ty: 0, scale: 1 };
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function wheelFactor(deltaY) {
// Standard "zoom in on scroll up" — positive deltaY zooms out.
return deltaY < 0 ? SCALE_STEP : 1 / SCALE_STEP;
}
/**
* Snap a value to the nearest multiple of gridSize.
* Returns the value unchanged when gridSize is 0 (snap disabled).
*/
function snap(value, gridSize) {
if (!gridSize || gridSize <= 0) return value;
return Math.round(value / gridSize) * gridSize;
}
// v4.13.0 — UMD wrapper. The browser global fallback lets the standalone
// flowchart window load this via <script> tag (contextIsolation:true means
// the renderer cannot require()), and lets the controller's jsdom tests
// load it via new Function(...) without a Node `require`.
const _exported = { zoomAt, panBy, reset, wheelFactor, snap, MIN_SCALE, MAX_SCALE };
if (typeof module === 'object' && module.exports) {
module.exports = _exported;
}
if (typeof window !== 'undefined') {
window.FlowchartViewport = _exported;
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Pure translator: graph → Visio (.vsdx) page1.xml.
*
* Generates the page-level XML that the main process wraps with the rest
* of the OOXML boilerplate and zips into a valid .vsdx file. Edits stay
* fully editable when opened in Microsoft Visio or vsdx-compatible tools
* (draw.io, Lucidchart, …).
*
* Coordinate system: the editor stores x, y, width, height in SVG pixels
* with origin top-left and Y growing downward. Visio uses inches with
* origin bottom-left and Y growing upward. We translate by:
*
* inchesPerPixel = 1 / 72 (72 SVG units per inch)
* pinX = (x + width/2) * inchesPerPixel
* pinY = pageHeightInches − (y + height/2) * inchesPerPixel
*
* `pageHeightInches` is computed from the graph bounds so the page is
* just tall enough to hold every shape with a half-inch margin.
*
* Pure module — no DOM, no globals.
*
* @module flowchart-vsdx-export
*/
'use strict';
// v4.13.0 — same default geometry the canvas uses.
const DEFAULT_WIDTH = 120;
const DEFAULT_HEIGHT = 60;
const MARGIN_INCHES = 0.5;
const INCHES_PER_PIXEL = 1 / 72;
// XML special characters that must be escaped in attribute / text values.
const XML_ESCAPES = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;',
};
function xmlEscape(value) {
if (value === null || value === undefined) return '';
return String(value).replace(/[&<>"']/g, (ch) => XML_ESCAPES[ch]);
}
function nodeWidth(node) {
return Number(node.width) > 0 ? Number(node.width) : DEFAULT_WIDTH;
}
function nodeHeight(_node) {
return DEFAULT_HEIGHT;
}
function shapeKindToVisioGeometry(kind) {
// Visio master shape names. We use generic Shape elements with the
// matching geometry; downstream tools that ship with these masters
// (Visio standard, draw.io) recognise the names and render the right
// shape. Falls back to a plain rectangle for unknown kinds.
switch (kind) {
case 'decision':
return { masterName: 'Decision / Diamond', geometry: 'diamond' };
case 'terminator':
return { masterName: 'Terminator / Oval', geometry: 'ellipse' };
case 'subroutine':
return { masterName: 'Subroutine', geometry: 'rect' };
case 'document':
return { masterName: 'Document', geometry: 'document' };
case 'process':
default:
return { masterName: 'Process', geometry: 'rect' };
}
}
/**
* Compute the page bounding box in pixels.
* Returns { width, height } in pixels; main process scales to inches.
*/
function graphBounds(graph) {
const nodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
if (nodes.length === 0) {
return { width: 200, height: 100 };
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of nodes) {
const w = nodeWidth(node);
const h = nodeHeight(node);
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + w);
maxY = Math.max(maxY, node.y + h);
}
return {
width: Math.max(maxX - minX, 100),
height: Math.max(maxY - minY, 100),
};
}
function shapeForNode(node, pageHeightInches) {
const w = nodeWidth(node);
const h = nodeHeight(node);
const centerXPx = node.x + w / 2;
const centerYPx = node.y + h / 2;
const pinX = centerXPx * INCHES_PER_PIXEL;
const pinY = pageHeightInches - centerYPx * INCHES_PER_PIXEL;
const widthIn = (w * INCHES_PER_PIXEL).toFixed(3);
const heightIn = (h * INCHES_PER_PIXEL).toFixed(3);
const { masterName } = shapeKindToVisioGeometry(node.kind);
const color = xmlEscape((node.color || '#ffffff').replace(/^#/, '').toUpperCase());
return (
` <Shape ID="${xmlEscape(node.id)}" NameU="${xmlEscape(masterName)}" ` +
`Name="${xmlEscape(node.label || node.id)}" Type="Shape" ` +
`LineStyle="0" FillStyle="0" TextStyle="0">\n` +
` <XForm>\n` +
` <PinX Unit="IN">${pinX.toFixed(3)}</PinX>\n` +
` <PinY Unit="IN">${pinY.toFixed(3)}</PinY>\n` +
` <Width Unit="IN">${widthIn}</Width>\n` +
` <Height Unit="IN">${heightIn}</Height>\n` +
` </XForm>\n` +
` <Fill Foreground="${color}">\n` +
` <FillBkgnd Type="Solid" Value="#FFFFFF"/>\n` +
` </Fill>\n` +
` <Char IX="0"/>\n` +
` <Para IX="0"/>\n` +
` <Text>${xmlEscape(node.label || '')}</Text>\n` +
` </Shape>`
);
}
function connectForEdge(edge) {
// Visio Connects pair shapes via their IDs using BeginX/EndX glue.
// We connect from shape(fromNodeId).BottomX to shape(toNodeId).TopX.
return (
` <Connect FromSheet="${xmlEscape(edge.fromNodeId)}" ` +
`FromCell="PinX" FromPart="9" ToSheet="${xmlEscape(edge.toNodeId)}" ` +
`ToCell="PinX" ToPart="12"/>`
);
}
/**
* Build the full page1.xml string for the given graph.
*
* @param {object} graph - { nodes: [...], edges: [...] } in editor coords.
* @returns {string} XML text suitable to drop into the visio/pages/ entry
* of the .vsdx zip.
*/
function toVisioPageXml(graph) {
const bounds = graphBounds(graph);
const heightIn = (bounds.height * INCHES_PER_PIXEL + MARGIN_INCHES * 2).toFixed(3);
const nodes = Array.isArray(graph && graph.nodes) ? graph.nodes : [];
const edges = Array.isArray(graph && graph.edges) ? graph.edges : [];
const shapes = nodes.map((n) => shapeForNode(n, Number(heightIn))).join('\n');
const connects = edges.map(connectForEdge).join('\n');
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<PageContents xmlns="http://schemas.microsoft.com/office/visio/2012/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">\n` +
` <Shapes>\n${shapes}\n </Shapes>\n` +
` <Connects>\n${connects}\n </Connects>\n` +
`</PageContents>\n`
);
}
/**
* Build the static boilerplate XML files for the .vsdx container.
* Returned as a map of zip-path -> string. Caller zips and writes.
*/
function visioBoilerplate() {
return {
'[Content_Types].xml':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">\n` +
` <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>\n` +
` <Default Extension="xml" ContentType="application/xml"/>\n` +
` <Override PartName="/visio/document.xml" ContentType="application/vnd.ms-visio.drawing+xml"/>\n` +
` <Override PartName="/visio/pages/pages.xml" ContentType="application/vnd.ms-visio.pages+xml"/>\n` +
` <Override PartName="/visio/pages/page1.xml" ContentType="application/vnd.ms-visio.page+xml"/>\n` +
` <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>\n` +
` <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>\n` +
`</Types>\n`,
'_rels/.rels':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n` +
` <Relationship Id="rId1" Type="http://schemas.microsoft.com/visio/2010/relationships/document" Target="visio/document.xml"/>\n` +
` <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>\n` +
` <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>\n` +
`</Relationships>\n`,
'docProps/core.xml':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ` +
`xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
`xmlns:dcterms="http://purl.org/dc/terms/" ` +
`xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n` +
` <dc:title>Flowchart</dc:title>\n` +
` <dc:creator>MarkdownConverter</dc:creator>\n` +
` <cp:lastModifiedBy>MarkdownConverter</cp:lastModifiedBy>\n` +
`</cp:coreProperties>\n`,
'docProps/app.xml':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ` +
`xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">\n` +
` <Application>MarkdownConverter</Application>\n` +
` <Template>Flowchart</Template>\n` +
`</Properties>\n`,
'visio/_rels/document.xml.rels':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n` +
` <Relationship Id="rId1" Type="http://schemas.microsoft.com/visio/2010/relationships/pages" Target="pages/pages.xml"/>\n` +
`</Relationships>\n`,
'visio/document.xml':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<VisioDocument xmlns="http://schemas.microsoft.com/office/visio/2012/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">\n` +
` <DocumentSettings TopPage="0" DefaultTextStyle="0" DefaultLineStyle="0" DefaultFillStyle="0" DefaultGuideStyle="0"/>\n` +
` <Colors>\n` +
` <ColorEntry IX="0" RGB="#000000"/>\n` +
` <ColorEntry IX="1" RGB="#FFFFFF"/>\n` +
` </Colors>\n` +
` <FaceNames>\n` +
` <FaceName ID="1" Name="Arial" UnicodeRanges="0 0 0 0" CharSets="1073742335 -65536" Panos="2 11 6 4 2 2 2 2 2 4" Flags="325"/>\n` +
` </FaceNames>\n` +
` <StyleSheets>\n` +
` <StyleSheet ID="0" NameU="No Style" Name="No Style">\n` +
` <Cell N="LineColor" V="0"/>\n` +
` <Cell N="LineWeight" V="1.0"/>\n` +
` <Cell N="FillForegnd" V="1"/>\n` +
` <Cell N="FillBkgnd" V="0"/>\n` +
` </StyleSheet>\n` +
` </StyleSheets>\n` +
` <Pages>\n` +
` <Page ID="0" NameU="Flowchart" Name="Flowchart">\n` +
` <PageSheet LineStyle="0" FillStyle="0" TextStyle="0">\n` +
` <Cell N="PageWidth" V="8.5"/>\n` +
` <Cell N="PageHeight" V="11"/>\n` +
` </PageSheet>\n` +
` <Rel r:id="rId1"/>\n` +
` </Page>\n` +
` </Pages>\n` +
`</VisioDocument>\n`,
'visio/pages/_rels/pages.xml.rels':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n` +
` <Relationship Id="rId1" Type="http://schemas.microsoft.com/visio/2010/relationships/page" Target="page1.xml"/>\n` +
`</Relationships>\n`,
'visio/pages/pages.xml':
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Pages xmlns="http://schemas.microsoft.com/office/visio/2012/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">\n` +
` <Page ID="0" NameU="Flowchart" Name="Flowchart" ViewScale="-1" ViewCenterX="4.25" ViewCenterY="5.5">\n` +
` <Rel r:id="rId1"/>\n` +
` </Page>\n` +
`</Pages>\n`,
};
}
if (typeof module === 'object' && module.exports) {
module.exports = { toVisioPageXml, visioBoilerplate, graphBounds };
}
if (typeof window !== 'undefined') {
window.FlowchartVsdxExport = { toVisioPageXml, visioBoilerplate, graphBounds };
}
+183 -218
View File
@@ -35,6 +35,111 @@
<link rel="stylesheet" href="styles-welcome.css" /> <link rel="stylesheet" href="styles-welcome.css" />
<link rel="stylesheet" href="styles-zen.css" /> <link rel="stylesheet" href="styles-zen.css" />
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css" /> <link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css" />
<!-- Editor theme CSS — one file per theme, all preloaded as disabled links.
src/renderer.js toggles `disabled` on the active one when the theme
changes. Source of truth: src/main/ThemeRegistry.bootstrap.js. -->
<link id="theme-atomonelight" rel="stylesheet" href="styles/themes/atomonelight.css" disabled />
<link id="theme-github" rel="stylesheet" href="styles/themes/github.css" disabled />
<link id="theme-light" rel="stylesheet" href="styles/themes/light.css" disabled />
<link id="theme-solarized" rel="stylesheet" href="styles/themes/solarized.css" disabled />
<link
id="theme-gruvbox-light"
rel="stylesheet"
href="styles/themes/gruvbox-light.css"
disabled
/>
<link id="theme-ayu-light" rel="stylesheet" href="styles/themes/ayu-light.css" disabled />
<link id="theme-sepia" rel="stylesheet" href="styles/themes/sepia.css" disabled />
<link id="theme-paper" rel="stylesheet" href="styles/themes/paper.css" disabled />
<link
id="theme-rosepine-dawn"
rel="stylesheet"
href="styles/themes/rosepine-dawn.css"
disabled
/>
<link
id="theme-concrete-light"
rel="stylesheet"
href="styles/themes/concrete-light.css"
disabled
/>
<link id="theme-dark" rel="stylesheet" href="styles/themes/dark.css" disabled />
<link id="theme-onedark" rel="stylesheet" href="styles/themes/onedark.css" disabled />
<link id="theme-dracula" rel="stylesheet" href="styles/themes/dracula.css" disabled />
<link id="theme-nord" rel="stylesheet" href="styles/themes/nord.css" disabled />
<link id="theme-monokai" rel="stylesheet" href="styles/themes/monokai.css" disabled />
<link id="theme-material" rel="stylesheet" href="styles/themes/material.css" disabled />
<link id="theme-gruvbox-dark" rel="stylesheet" href="styles/themes/gruvbox-dark.css" disabled />
<link id="theme-tokyonight" rel="stylesheet" href="styles/themes/tokyonight.css" disabled />
<link id="theme-palenight" rel="stylesheet" href="styles/themes/palenight.css" disabled />
<link id="theme-ayu-dark" rel="stylesheet" href="styles/themes/ayu-dark.css" disabled />
<link id="theme-ayu-mirage" rel="stylesheet" href="styles/themes/ayu-mirage.css" disabled />
<link id="theme-oceanic-next" rel="stylesheet" href="styles/themes/oceanic-next.css" disabled />
<link id="theme-cobalt2" rel="stylesheet" href="styles/themes/cobalt2.css" disabled />
<link
id="theme-concrete-dark"
rel="stylesheet"
href="styles/themes/concrete-dark.css"
disabled
/>
<link
id="theme-concrete-warm"
rel="stylesheet"
href="styles/themes/concrete-warm.css"
disabled
/>
<link
id="theme-catppuccin-latte"
rel="stylesheet"
href="styles/themes/catppuccin-latte.css"
disabled
/>
<link
id="theme-catppuccin-frappe"
rel="stylesheet"
href="styles/themes/catppuccin-frappe.css"
disabled
/>
<link
id="theme-catppuccin-macchiato"
rel="stylesheet"
href="styles/themes/catppuccin-macchiato.css"
disabled
/>
<link
id="theme-catppuccin-mocha"
rel="stylesheet"
href="styles/themes/catppuccin-mocha.css"
disabled
/>
<link id="theme-one-light" rel="stylesheet" href="styles/themes/one-light.css" disabled />
<link
id="theme-tokyo-night-storm"
rel="stylesheet"
href="styles/themes/tokyo-night-storm.css"
disabled
/>
<link id="theme-synthwave-84" rel="stylesheet" href="styles/themes/synthwave-84.css" disabled />
<link id="theme-outrun" rel="stylesheet" href="styles/themes/outrun.css" disabled />
<link
id="theme-winter-is-coming-light"
rel="stylesheet"
href="styles/themes/winter-is-coming-light.css"
disabled
/>
<link
id="theme-winter-is-coming-dark"
rel="stylesheet"
href="styles/themes/winter-is-coming-dark.css"
disabled
/>
<link
id="theme-solarized-dark-hc"
rel="stylesheet"
href="styles/themes/solarized-dark-hc.css"
disabled
/>
<link id="theme-spring-light" rel="stylesheet" href="styles/themes/spring-light.css" disabled />
<!-- KaTeX math rendering — bundled locally (fonts included) for offline use --> <!-- KaTeX math rendering — bundled locally (fonts included) for offline use -->
<link rel="stylesheet" href="../assets/katex/katex.min.css" /> <link rel="stylesheet" href="../assets/katex/katex.min.css" />
</head> </head>
@@ -809,224 +914,6 @@
</div> </div>
</div> </div>
<!-- ASCII Art Generator Dialog -->
<div
id="ascii-art-dialog"
class="modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="ascii-art-title"
>
<div class="modal-backdrop" data-close></div>
<div class="modal-content large">
<div class="modal-header">
<h3 id="ascii-art-title">🎨 ASCII Art Generator</h3>
<button class="modal-close" id="ascii-dialog-close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<div class="export-section">
<label>Mode:</label>
<div style="display: flex; gap: 10px; margin-bottom: 10px">
<button id="ascii-mode-text" class="btn-secondary ascii-mode-btn active">
Text Banner
</button>
<button id="ascii-mode-box" class="btn-secondary ascii-mode-btn">Box/Frame</button>
<button id="ascii-mode-templates" class="btn-secondary ascii-mode-btn">
Templates
</button>
</div>
</div>
<!-- Text Banner Mode -->
<div id="ascii-text-mode" class="ascii-mode-section">
<div class="export-section">
<label for="ascii-text-input">Text to Convert:</label>
<input
type="text"
id="ascii-text-input"
placeholder="Enter your text..."
maxlength="50"
/>
</div>
<div class="export-section">
<label for="ascii-font-style">Style:</label>
<select id="ascii-font-style">
<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="ascii-box-mode" class="ascii-mode-section hidden">
<div class="export-section">
<label for="ascii-box-text">Text Content:</label>
<textarea
id="ascii-box-text"
rows="3"
placeholder="Enter text for the box..."
></textarea>
</div>
<div class="export-section">
<label for="ascii-box-style">Box Style:</label>
<select id="ascii-box-style">
<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="export-section">
<label for="ascii-box-padding">Padding:</label>
<input
type="number"
id="ascii-box-padding"
min="1"
max="10"
value="2"
style="width: 80px"
/>
</div>
</div>
<!-- Templates Mode -->
<div id="ascii-templates-mode" class="ascii-mode-section hidden">
<div class="export-section">
<label>Arrows & Flow:</label>
<div
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 15px;
"
>
<button class="btn-secondary ascii-template-btn" data-template="arrow-right">
Arrow →
</button>
<button class="btn-secondary ascii-template-btn" data-template="arrow-down">
Arrow ↓
</button>
<button class="btn-secondary ascii-template-btn" data-template="decision">
Decision
</button>
<button class="btn-secondary ascii-template-btn" data-template="process-flow">
Process Flow
</button>
</div>
<label>Diagrams & Charts:</label>
<div
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 15px;
"
>
<button class="btn-secondary ascii-template-btn" data-template="flowchart">
Flowchart
</button>
<button class="btn-secondary ascii-template-btn" data-template="sequence">
Sequence
</button>
<button class="btn-secondary ascii-template-btn" data-template="network">
Network
</button>
<button class="btn-secondary ascii-template-btn" data-template="hierarchy">
Hierarchy
</button>
<button class="btn-secondary ascii-template-btn" data-template="timeline">
Timeline
</button>
<button class="btn-secondary ascii-template-btn" data-template="table-simple">
Table
</button>
</div>
<label>Boxes & Containers:</label>
<div
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 15px;
"
>
<button class="btn-secondary ascii-template-btn" data-template="header">
Header
</button>
<button class="btn-secondary ascii-template-btn" data-template="note-box">
Note Box
</button>
<button class="btn-secondary ascii-template-btn" data-template="warning-box">
Warning
</button>
<button class="btn-secondary ascii-template-btn" data-template="info-box">
Info Box
</button>
</div>
<label>Decorative Elements:</label>
<div
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 15px;
"
>
<button class="btn-secondary ascii-template-btn" data-template="divider">
Divider
</button>
<button class="btn-secondary ascii-template-btn" data-template="separator-fancy">
Separator
</button>
<button class="btn-secondary ascii-template-btn" data-template="brackets">
Brackets
</button>
<button class="btn-secondary ascii-template-btn" data-template="banner-stars">
Banner
</button>
<button class="btn-secondary ascii-template-btn" data-template="check">
Checklist ✓
</button>
<button class="btn-secondary ascii-template-btn" data-template="progress-bar">
Progress Bar
</button>
</div>
</div>
</div>
<div class="export-section">
<label for="ascii-preview">Preview:</label>
<pre
id="ascii-preview"
style="
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
font-size: 12px;
max-height: 400px;
overflow: auto;
font-family: 'Courier New', monospace;
line-height: 1.2;
"
></pre>
</div>
</div>
<div class="modal-footer">
<button id="ascii-generate" class="btn btn-secondary">Generate Preview</button>
<button id="ascii-insert" class="btn btn-primary">Insert ASCII Art</button>
<button id="ascii-cancel" class="btn btn-secondary" data-close>Cancel</button>
</div>
</div>
</div>
<!-- Universal File Converter Dialog --> <!-- Universal File Converter Dialog -->
<div <div
id="universal-converter-dialog" id="universal-converter-dialog"
@@ -2558,6 +2445,70 @@
<polyline points="12 6 12 12 16 14" /> <polyline points="12 6 12 12 16 14" />
</svg> </svg>
</button> </button>
<!-- Search + Q&A: workspace content search / doc-aware Q&A -->
<button
class="sidebar-icon"
data-panel="search"
title="Search &amp; Ask (Ctrl+Shift+F)"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
<!-- Daily notes: list of YYYY-MM-DD.md files + Today button -->
<button class="sidebar-icon" data-panel="daily-notes" title="Daily Notes (Ctrl+Alt+D)">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
<!-- Flow Chart Editor: visual Mermaid flowchart builder -->
<button
class="sidebar-icon"
data-panel="flowchart"
title="Flow Chart Editor (Ctrl+Alt+F)"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="6" height="4" rx="1" />
<rect x="15" y="3" width="6" height="4" rx="1" />
<polygon points="9,17 12,13 15,17" />
<line x1="6" y1="7" x2="6" y2="11" />
<line x1="18" y1="7" x2="18" y2="11" />
<line x1="6" y1="11" x2="9" y2="15" />
<line x1="18" y1="11" x2="15" y2="15" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</button>
</div> </div>
<div class="sidebar-panel" id="sidebar-panel"> <div class="sidebar-panel" id="sidebar-panel">
<div class="sidebar-panel-header"> <div class="sidebar-panel-header">
@@ -2831,6 +2782,20 @@
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="char-count">Chars: 0</span> <span class="status-item" id="char-count">Chars: 0</span>
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span
class="status-item"
id="reading-time"
title="Estimated reading time at 220 words/minute"
>~0 min read</span
>
<span class="status-separator">|</span>
<span
class="status-item"
id="grade-level"
title="Flesch-Kincaid grade level (lower = easier)"
>Grade —</span
>
<span class="status-separator">|</span>
<span class="status-item" id="line-col">Ln 1, Col 1</span> <span class="status-item" id="line-col">Ln 1, Col 1</span>
<span class="status-separator">|</span> <span class="status-separator">|</span>
<span class="status-item" id="encoding">UTF-8</span> <span class="status-item" id="encoding">UTF-8</span>
+710 -178
View File
File diff suppressed because it is too large Load Diff
+195
View File
@@ -255,8 +255,203 @@ async function complete(request, options = {}) {
return { content }; return { content };
} }
/**
* Stream a chat completion as an async iterable of text chunks.
*
* Supports the same providers as complete(). For SSE-supporting endpoints
* (openai, anthropic, and their compatible variants; ollama and lmstudio
* transparently support the openai schema), parses the chunked response
* and yields each delta. For endpoints without working streaming, falls
* back to a single chunk containing the full response.
*
* @param {object} request - same shape as complete()
* @param {object} [options] - { fetchImpl, timeoutMs, signal }
* @returns {AsyncIterable<string>}
* @throws {AiProviderError} only on synchronous setup failures (auth,
* bad URL, oversized prompt). Network/HTTP errors during streaming are
* thrown when the consumer awaits a yield that follows the failure.
*/
async function* completeStream(request, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== 'function') {
throw new AiProviderError('No fetch implementation available.', 'no_fetch');
}
const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
if (!Array.isArray(request?.messages) || request.messages.length === 0) {
throw new AiProviderError('No messages provided.', 'no_messages');
}
const totalChars =
(request.system?.length || 0) +
request.messages.reduce((n, m) => n + (m?.content?.length || 0), 0);
if (totalChars > MAX_PROMPT_CHARS) {
throw new AiProviderError(
'Prompt is too large (over 200KB). Try a smaller selection.',
'prompt_too_large'
);
}
const settings = resolveSettings(request);
if (OPENAI_STYLE.has(settings.provider)) {
yield* streamOpenAiStyle(settings, request, fetchImpl, timeoutMs, options.signal);
return;
}
yield* streamAnthropic(settings, request, fetchImpl, timeoutMs, options.signal);
}
async function* streamOpenAiStyle(settings, request, fetchImpl, timeoutMs, callerSignal) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onCallerAbort = () => controller.abort();
if (callerSignal) {
if (callerSignal.aborted) controller.abort();
else callerSignal.addEventListener('abort', onCallerAbort, { once: true });
}
try {
const body = {
model: settings.model,
temperature: settings.temperature,
stream: true,
messages: [
...(settings ? [{ role: 'system', content: settings.system || request.system }] : []),
...request.messages.map((m) => ({ role: m.role, content: m.content })),
],
};
const headers = { 'Content-Type': 'application/json' };
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`;
const response = await fetchImpl(`${settings.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
...body,
messages: [
...(request.system ? [{ role: 'system', content: request.system }] : []),
...request.messages.map((m) => ({ role: m.role, content: m.content })),
],
}),
signal: controller.signal,
});
if (!response.ok) {
throw new AiProviderError(
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
`http_${response.status}`
);
}
yield* parseSseStream(response, (data) => {
try {
const parsed = JSON.parse(data);
return parsed?.choices?.[0]?.delta?.content || '';
} catch {
return '';
}
});
} finally {
clearTimeout(timer);
if (callerSignal) callerSignal.removeEventListener('abort', onCallerAbort);
}
}
async function* streamAnthropic(settings, request, fetchImpl, timeoutMs, callerSignal) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onCallerAbort = () => controller.abort();
if (callerSignal) {
if (callerSignal.aborted) controller.abort();
else callerSignal.addEventListener('abort', onCallerAbort, { once: true });
}
try {
const body = {
model: settings.model,
max_tokens: 4096,
temperature: settings.temperature,
stream: true,
system: request.system || undefined,
messages: request.messages.map((m) => ({ role: m.role, content: m.content })),
};
const headers = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
};
if (settings.apiKey) {
headers['x-api-key'] = settings.apiKey;
headers.Authorization = `Bearer ${settings.apiKey}`;
}
const messagesUrl = settings.baseUrl.endsWith('/v1')
? `${settings.baseUrl}/messages`
: `${settings.baseUrl}/v1/messages`;
const response = await fetchImpl(messagesUrl, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
throw new AiProviderError(
`AI request failed (HTTP ${response.status}). Check the model name, API key, and base URL.`,
`http_${response.status}`
);
}
// Anthropic SSE uses event: content_block_delta + data: {delta:{text}}
yield* parseSseStream(response, (data) => {
try {
const parsed = JSON.parse(data);
if (parsed?.type === 'content_block_delta') {
return parsed?.delta?.text || '';
}
return '';
} catch {
return '';
}
});
} finally {
clearTimeout(timer);
if (callerSignal) callerSignal.removeEventListener('abort', onCallerAbort);
}
}
/**
* Parse an SSE stream from a Response. Yields parsed chunks per `data:` line.
* Handles the [DONE] sentinel used by OpenAI.
*/
async function* parseSseStream(response, parseData) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
if (line.startsWith('data:')) {
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
const chunk = parseData(payload);
if (chunk) yield chunk;
} else if (line.startsWith('event:')) {
// Anthropic event type is captured via parseData on the following
// data: line. Nothing to do at event: prefix.
}
}
}
} finally {
try {
reader.releaseLock();
} catch {
// already released
}
}
}
module.exports = { module.exports = {
complete, complete,
completeStream,
resolveSettings, resolveSettings,
AiProviderError, AiProviderError,
PROVIDER_DEFAULTS, PROVIDER_DEFAULTS,
+71
View File
@@ -0,0 +1,71 @@
/**
* Figlet adapter — lazy-loads the figlet package and caches the font list.
*
* The figlet npm package ships ~400 fonts. Lazy-loading on first
* `generateFiglet` / `listFigletFonts` call keeps app startup fast. If
* `require('figlet')` itself fails (corrupt install), the adapter reports
* the system as unavailable instead of crashing.
*
* @module AsciiArt.figlet-adapter
*/
'use strict';
class AsciiArtFigletError extends Error {
constructor(message, cause) {
super(message);
this.name = 'AsciiArtFigletError';
this.cause = cause;
}
}
let _figlet = undefined; // undefined = not yet attempted; null = failed
let _figletFonts = undefined; // undefined = not yet listed
function loadFiglet() {
if (_figlet !== undefined) return _figlet;
try {
_figlet = require('figlet');
} catch {
_figlet = null;
}
return _figlet;
}
function listFigletFonts() {
const f = loadFiglet();
if (!f) return [];
if (_figletFonts === undefined) {
try {
_figletFonts = f.fontsSync();
} catch {
_figletFonts = [];
}
}
return _figletFonts.slice();
}
function generateFiglet(text, font) {
const f = loadFiglet();
if (!f) {
throw new AsciiArtFigletError('figlet is not available');
}
try {
return f.textSync(String(text ?? ''), { font });
} catch (err) {
throw new AsciiArtFigletError(`figlet render failed for font "${font}": ${err.message}`, err);
}
}
// Test-only cache reset; not used at runtime.
function _resetCache() {
_figlet = undefined;
_figletFonts = undefined;
}
module.exports = {
AsciiArtFigletError,
loadFiglet,
listFigletFonts,
generateFiglet,
_resetCache,
};
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
/**
* ASCII art orchestrator — pure module.
*
* Public API:
* - generate({ text, font, options }) → string
* - listFonts() → [{ id, label, kind, sample }]
* - getFontMeta(id) → { kind, height?, supportedChars? } | null
*
* `font` is a single string that namespaces each backend:
* - bare id ('standard', 'big', 'isometric1', ...) → hand-coded
* - 'template:<name>' → templates
* - 'figlet:<font>' → lazy figlet
*
* Pure: no Electron, no fs, no IPC. Wired to main.js via ipcMain.handle.
*
* @module AsciiArt
*/
'use strict';
const { HAND_CODED_FONTS } = require('./AsciiArt.fonts');
const { getTemplate, ASCII_TEMPLATES } = require('./AsciiArt.templates');
const FigletAdapter = require('./AsciiArt.figlet-adapter');
const SAMPLE_TEXT = 'HELLO';
function _renderHandCoded(text, fontId) {
const font = HAND_CODED_FONTS[fontId];
if (!font) return null;
const upper = String(text ?? '').toUpperCase();
const lines = Array(font.height).fill('');
for (const ch of upper) {
const glyph = font.chars[ch] || font.chars[' '];
for (let i = 0; i < font.height; i++) lines[i] += glyph[i];
}
return lines.join('\n');
}
function generate({ text, font, options: _options } = {}) {
const f = typeof font === 'string' && font ? font : 'standard';
if (f.startsWith('template:')) {
return getTemplate(f.slice('template:'.length));
}
if (f.startsWith('figlet:')) {
return FigletAdapter.generateFiglet(String(text ?? ''), f.slice('figlet:'.length));
}
const rendered = _renderHandCoded(text, f);
return rendered !== null ? rendered : _renderHandCoded(text, 'standard');
}
function listFonts() {
const out = [];
for (const [id] of Object.entries(HAND_CODED_FONTS)) {
out.push({
id,
label: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
kind: 'hand-coded',
sample: _renderHandCoded(SAMPLE_TEXT, id),
});
}
for (const name of FigletAdapter.listFigletFonts()) {
out.push({
id: `figlet:${name}`,
label: `FIGlet · ${name}`,
kind: 'figlet',
sample: '',
});
}
for (const name of Object.keys(ASCII_TEMPLATES)) {
out.push({
id: `template:${name}`,
label: `Template · ${name.replace(/-/g, ' ')}`,
kind: 'template',
sample: ASCII_TEMPLATES[name],
});
}
return out;
}
function getFontMeta(id) {
if (typeof id !== 'string' || !id) return null;
if (id.startsWith('template:')) {
return ASCII_TEMPLATES[id.slice('template:'.length)] ? { kind: 'template' } : null;
}
if (id.startsWith('figlet:')) {
const name = id.slice('figlet:'.length);
return FigletAdapter.listFigletFonts().includes(name) ? { kind: 'figlet' } : null;
}
const font = HAND_CODED_FONTS[id];
if (!font) return null;
return {
kind: 'hand-coded',
height: font.height,
supportedChars: Object.keys(font.chars),
};
}
module.exports = { generate, listFonts, getFontMeta };
+62
View File
@@ -0,0 +1,62 @@
/**
* ASCII art templates — named pre-drawn diagrams and frames.
*
* 19 entries; unknown names resolve to ''. `arrow-up` is the only template
* authored for this module — it completes the arrow triplet (right, down, up)
* and is not present in the legacy templates.
*
* @module AsciiArt.templates
*/
'use strict';
const ASCII_TEMPLATES = {
// ----- arrows -----
'arrow-right':
' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'arrow-down':
' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
// Newly authored to complete the arrow triplet. Mirrors arrow-down with
// upward flow direction.
'arrow-up':
' ▲\n │\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▲',
// ----- shapes -----
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 └───────┘ └───────┘ └───────┘',
// ----- frames -----
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',
// ----- bars / tables (only in src/renderer.js) -----
'progress-bar': '\nProgress: [████████████░░░░░░░░] 60%\n 0% 100%',
'table-simple':
'\n┌──────────┬──────────┬──────────┐\n│ Header 1 │ Header 2 │ Header 3 │\n├──────────┼──────────┼──────────┤\n│ Data 1 │ Data 2 │ Data 3 │\n├──────────┼──────────┼──────────┤\n│ Data 4 │ Data 5 │ Data 6 │\n└──────────┴──────────┴──────────┘',
};
function getTemplate(name) {
if (typeof name !== 'string') return '';
return ASCII_TEMPLATES[name] || '';
}
module.exports = { ASCII_TEMPLATES, getTemplate };
+178
View File
@@ -0,0 +1,178 @@
/**
* Autosave + crash-recovery buffer.
*
* Mirrors VersionHistory's pattern (per-doc isolated folder, injectable IO for
* tests) but covers a different gap: VersionHistory snapshots the *previous*
* content on every explicit save, so an unsaved buffer is lost when the app
* crashes. AutosaveBuffer fills that gap by periodically (renderer-driven)
* writing the current in-memory buffer to a recovery store.
*
* Recovery model:
* - <userData>/autosave/by-path/<sha1-of-path>/recovery.md — latest buffer
* - <userData>/autosave/by-path/<sha1-of-path>/meta.json — {docPath, savedAt, byteSize, appVersion}
*
* One recovery entry per document is sufficient — newer writes overwrite older
* ones. There's no history to keep (VersionHistory handles that).
*
* Safety:
* - Writes are atomic via writeFileSync (small files; loss of partial write
* is preferable to corrupted recovery)
* - meta.json is read defensively; missing/corrupt = empty list
* - All fs/path access is injectable for unit tests
*
* @module AutosaveBuffer
*/
function defaultCrypto() {
return require('crypto');
}
function folderFor(docPath, pathUtil, crypto) {
const hash = crypto.createHash('sha1').update(String(docPath)).digest('hex').slice(0, 16);
return pathUtil.join('by-path', hash);
}
/**
* Write the current buffer for a document. Overwrites any prior recovery
* entry — the latest buffer is the only one that matters for crash recovery.
*
* @param {object} args
* @param {string} args.docPath Absolute path of the document (use a synthetic
* key like 'untitled-tab-<tabId>' for tabs that have never been saved)
* @param {string} args.content Buffer content to persist
* @param {string} [args.appVersion] Stored so a future schema change can ignore stale entries
* @param {object} args.io { rootDir, fs, pathUtil, crypto }
* @returns {{savedAt:number, byteSize:number}} meta written alongside the blob
*/
function writeRecovery({ docPath, content, appVersion = 'unknown', io }) {
if (!docPath || typeof docPath !== 'string') {
throw new Error('AutosaveBuffer: docPath is required');
}
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
fs.mkdirSync(dir, { recursive: true });
const body = String(content ?? '');
const savedAt = Date.now();
const byteSize = Buffer.byteLength(body, 'utf-8');
fs.writeFileSync(pathUtil.join(dir, 'recovery.md'), body, 'utf-8');
fs.writeFileSync(
pathUtil.join(dir, 'meta.json'),
JSON.stringify({ docPath, savedAt, byteSize, appVersion }, null, 2),
'utf-8'
);
return { savedAt, byteSize };
}
/**
* Read the recovery blob + meta for a document. Returns null when no entry
* exists; throws when meta.json is present but corrupt (so the renderer can
* surface a "couldn't read recovery" warning instead of silently swallowing it).
*
* @returns {{docPath:string, content:string, savedAt:number, byteSize:number, appVersion:string}|null}
*/
function readRecovery({ docPath, io }) {
if (!docPath || typeof docPath !== 'string') {
throw new Error('AutosaveBuffer: docPath is required');
}
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
const metaPath = pathUtil.join(dir, 'meta.json');
const blobPath = pathUtil.join(dir, 'recovery.md');
let meta;
try {
meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
} catch (err) {
if (err.code === 'ENOENT') return null;
throw new Error(`AutosaveBuffer: corrupt meta.json for ${docPath}: ${err.message}`);
}
let content;
try {
content = fs.readFileSync(blobPath, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
return {
docPath: meta.docPath || docPath,
content,
savedAt: meta.savedAt || 0,
byteSize: meta.byteSize ?? Buffer.byteLength(content, 'utf-8'),
appVersion: meta.appVersion || 'unknown',
};
}
/**
* Delete the recovery entry for a document. Called after a successful explicit
* save (the buffer is now on disk at its real path) or after the user
* dismisses the recovery banner.
*
* @returns {boolean} whether an entry was removed
*/
function clearRecovery({ docPath, io }) {
if (!docPath || typeof docPath !== 'string') {
throw new Error('AutosaveBuffer: docPath is required');
}
const { rootDir, fs, pathUtil, crypto = defaultCrypto() } = io;
const dir = pathUtil.join(rootDir, folderFor(docPath, pathUtil, crypto));
let removedAny = false;
for (const file of ['recovery.md', 'meta.json']) {
try {
fs.unlinkSync(pathUtil.join(dir, file));
removedAny = true;
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
return removedAny;
}
/**
* List every recovery entry across all documents. Used at startup to decide
* whether to show a "recover unsaved work?" prompt.
*
* @returns {Array<{docPath:string, savedAt:number, byteSize:number, appVersion:string}>}
*/
function listRecoveries({ io }) {
const { rootDir, fs, pathUtil } = io;
const baseDir = pathUtil.join(rootDir, 'by-path');
let dirs;
try {
dirs = fs.readdirSync(baseDir);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const hash of dirs) {
const metaPath = pathUtil.join(baseDir, hash, 'meta.json');
try {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
if (meta && typeof meta.docPath === 'string') {
out.push({
docPath: meta.docPath,
savedAt: meta.savedAt || 0,
byteSize: meta.byteSize || 0,
appVersion: meta.appVersion || 'unknown',
});
}
} catch {
// Corrupt or missing meta — skip; the next write will rewrite it
}
}
// Newest first — most likely to be relevant
out.sort((a, b) => b.savedAt - a.savedAt);
return out;
}
module.exports = {
writeRecovery,
readRecovery,
clearRecovery,
listRecoveries,
folderFor,
};
+136
View File
@@ -0,0 +1,136 @@
/**
* Daily notes — Zettelkasten/journal entry helper.
*
* Convention: a folder of YYYY-MM-DD.md files (PKM/Obsidian/Logseq style).
* `pathFor(date, dir)` returns the absolute path for a given date. The
* template loader returns a starting skeleton the user can fill in; if the
* file already exists, we return its current content instead (no clobber).
*
* Implementation is pure (no fs/IPC coupling) — main.js wires the IO and
* template path. Tests inject a virtual fs + path util.
*/
const DEFAULT_TEMPLATE_NAME = 'daily.md';
/**
* Format a Date as YYYY-MM-DD in local time (matches the on-disk filename).
* Returns 'YYYY-MM-DD' string. Today (no arg) defaults to new Date().
*/
function dateKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/**
* Absolute path for the daily note of `date` inside `dir`. Pure.
*/
function pathFor(date, dir, pathUtil) {
return pathUtil.join(dir, `${dateKey(date)}.md`);
}
/**
* Read the daily note skeleton. Looks in `templateDir` for a file named
* `templateName` (default 'daily.md'); if missing, returns a built-in
* default so the feature works out-of-the-box without setup.
*
* Returns the template string with any {date}/{weekday} placeholders
* substituted.
*/
function loadTemplate({
date,
templateDir,
templateName = DEFAULT_TEMPLATE_NAME,
fs,
pathUtil,
now = new Date(),
}) {
let body = `# ${dateKey(date)}\n\n## Notes\n\n`;
if (templateDir && fs) {
const templatePath = pathUtil.join(templateDir, templateName);
try {
body = fs.readFileSync(templatePath, 'utf-8');
} catch {
/* fall through to default */
}
}
return body
.replace(/\{date\}/g, dateKey(date))
.replace(/\{weekday\}/g, now.toLocaleDateString('en-US', { weekday: 'long' }));
}
/**
* Open or create the daily note for `date` inside `dir`. Returns
* { path, content, created: boolean }.
*
* Behavior:
* - If <dir>/<date>.md exists, return its content (created: false).
* - Otherwise load the template, write it, and return it (created: true).
*
* Caller (main.js IPC handler) decides what to do with `created` —
* typically: open in the existing tab if any, else createNewTab.
*/
function openOrCreate({ date, dir, templateDir, fs, pathUtil, now = new Date(), seedContent }) {
if (!dir) throw new Error('DailyNotes: dir is required');
fs.mkdirSync(dir, { recursive: true });
const notePath = pathFor(date, dir, pathUtil);
let content;
let created = false;
try {
content = fs.readFileSync(notePath, 'utf-8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
// Use the explicit seedContent when provided (e.g. a non-default
// template chosen via the gallery); otherwise fall back to the
// template-loader (which honors templateDir + the built-in default).
content =
typeof seedContent === 'string' && seedContent.length > 0
? seedContent
: loadTemplate({ date, templateDir, fs, pathUtil, now });
fs.writeFileSync(notePath, content, 'utf-8');
created = true;
}
return { path: notePath, content, created };
}
/**
* List existing daily-note filenames in `dir`, newest first.
* Returns ['2026-09-13.md', '2026-09-12.md', ...] — only entries that
* match the YYYY-MM-DD.md shape so a stray readme.md in the folder is
* ignored.
*/
function listExisting({ dir, fs }) {
let entries;
try {
entries = fs.readdirSync(dir);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
// Strict-enough date pattern: year-month-day with month 01-12 and day 01-31.
// We don't enforce real-calendar validity (Feb 30 still matches) — that's
// the user's problem to fix, not ours to silently drop.
const pattern = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\.md$/;
return entries.filter((name) => pattern.test(name)).sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
}
/**
* Validate a `dir` candidate for daily notes. Used by the IPC handler so a
* renderer compromise can't point this at /etc or other sensitive paths —
* any path outside userData/templates is rejected by main.js's existing
* validatePath() before we get here.
*/
function isValidDir(dir) {
return typeof dir === 'string' && dir.length > 0 && !dir.includes('\0');
}
module.exports = {
dateKey,
pathFor,
loadTemplate,
openOrCreate,
listExisting,
isValidDir,
DEFAULT_TEMPLATE_NAME,
};
+83
View File
@@ -0,0 +1,83 @@
/**
* Daily-note template gallery.
*
* The user can keep multiple daily-note skeletons in
* <userData>/notes/templates/ — each .md file in that directory is one
* template. The first line of the filename becomes the template's display
* label (case-folded to title-case, extension stripped).
*
* Pure module: takes a `rootDir` and injectable IO so it stays unit-testable.
*
* @module DailyNotesTemplates
*/
/** Title-case a name. "morning-pages.md" → "Morning Pages". */
function labelFor(filename) {
if (typeof filename !== 'string' || filename.length === 0) return '';
const stem = filename.replace(/\.md$/i, '');
return stem
.split(/[-_\s]+/)
.filter((s) => s.length > 0)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
.join(' ');
}
/** Read the template files in `dir` (non-recursive). */
function listTemplates({ dir, fs, pathUtil }) {
if (!dir) return [];
let entries;
try {
entries = fs.readdirSync(dir);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const name of entries) {
if (!/\.md$/i.test(name)) continue;
let content;
try {
content = fs.readFileSync(pathUtil.join(dir, name), 'utf-8');
} catch {
continue;
}
out.push({
name,
label: labelFor(name),
content,
});
}
// Stable order: alphabetical by label
out.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
return out;
}
/** Write a new template file. Returns the saved entry. */
function saveTemplate({ dir, name, content, fs, pathUtil }) {
if (!dir) throw new Error('DailyNotesTemplates: dir is required');
if (!name || typeof name !== 'string') throw new Error('name is required');
if (!/\.md$/i.test(name)) name = `${name}.md`;
fs.mkdirSync(dir, { recursive: true });
const fullPath = pathUtil.join(dir, name);
fs.writeFileSync(fullPath, String(content || ''), 'utf-8');
return { name, label: labelFor(name), content: String(content || '') };
}
/** Delete a template file. Returns true if removed. */
function deleteTemplate({ dir, name, fs, pathUtil }) {
if (!dir || !name) return false;
try {
fs.unlinkSync(pathUtil.join(dir, name));
return true;
} catch (err) {
if (err.code === 'ENOENT') return false;
throw err;
}
}
module.exports = {
labelFor,
listTemplates,
saveTemplate,
deleteTemplate,
};
+250
View File
@@ -0,0 +1,250 @@
/**
* Doc-aware Q&A — thin wrapper over WorkspaceSearch tuned for questions.
*
* User asks "what did I write about rust async last week?" and gets back the
* top-K chunks from their workspace ranked by relevance. No neural model, no
* API key — the same TF-idf-style ranking WorkspaceSearch uses, with a few
* QA-specific tweaks:
*
* - Question words (what/how/why/when/where/who/which/does/is/are/...) are
* stripped before ranking — they don't carry meaning, just grammar
* - The top chunks are split out as independent results so the renderer
* can show "3 passages from 2 files" instead of one giant result
* - Recency weight is doubled: "last week" implies the user wants fresh
* content, not old material
*
* Future upgrade path: swap `WorkspaceSearch.search` for a vector-similarity
* call (transformers.js in the renderer, or a sidecar process). The public
* shape — {question, chunks:[{filePath, snippet, score}]} — stays the same.
*
* @module DocQA
*/
const WorkspaceSearch = require('./WorkspaceSearch');
const SemanticEngine = require('./SemanticEngine');
const QUESTION_WORDS = new Set([
'what',
'when',
'where',
'who',
'whom',
'whose',
'why',
'how',
'which',
'whether',
'does',
'do',
'did',
'is',
'are',
'was',
'were',
'be',
'been',
'being',
'have',
'has',
'had',
'can',
'could',
'would',
'should',
'will',
'shall',
'may',
'might',
'i',
'me',
'my',
'we',
'our',
'you',
'your',
'they',
'them',
'their',
'a',
'an',
'the',
'and',
'or',
'but',
'so',
'of',
'to',
'in',
'on',
'at',
'for',
'with',
'about',
'into',
'from',
'by',
'as',
'this',
'that',
'these',
'those',
'it',
'its',
'write',
'wrote',
'written',
'read',
'think',
'know',
'find',
'show',
'tell',
'say',
'see',
'use',
'used',
]);
/**
* Strip question words from a raw question so WorkspaceSearch's bare-term
* matching isn't drowned in grammar noise. Quoted phrases, #tags, and
* @wikilinks pass through unchanged.
*/
function cleanQuestion(raw) {
if (typeof raw !== 'string') return '';
// First pull out facets and phrases so we don't strip them
const facets = [];
const working = raw.replace(/[#@][A-Za-z0-9_-]+|"[^"]+"/g, (m) => {
facets.push(m);
return ' ';
});
const words = working.split(/\s+/).filter((w) => {
const lower = w.toLowerCase().replace(/[^a-z0-9-]/g, '');
return lower.length >= 2 && !QUESTION_WORDS.has(lower);
});
return [...words, ...facets].join(' ').trim();
}
/**
* Split a document into rough passages (~800 chars or paragraph break,
* whichever comes first). Returns an array of {start, end, text} so the
* caller can build snippets. Pure.
*/
function chunkDocument(content, maxChunkChars = 800) {
if (typeof content !== 'string' || content.length === 0) return [];
const chunks = [];
const paragraphs = content.split(/\n\s*\n/);
let buffer = '';
let startOffset = 0;
for (const para of paragraphs) {
// If a single paragraph exceeds the cap, hard-split it by character.
if (para.length > maxChunkChars) {
// Flush whatever was buffered first.
if (buffer) {
chunks.push({ start: startOffset, end: startOffset + buffer.length, text: buffer });
startOffset += buffer.length + 2;
buffer = '';
}
for (let i = 0; i < para.length; i += maxChunkChars) {
const slice = para.slice(i, i + maxChunkChars);
chunks.push({ start: startOffset, end: startOffset + slice.length, text: slice });
startOffset += slice.length;
}
continue;
}
const tentative = buffer ? `${buffer}\n\n${para}` : para;
if (tentative.length > maxChunkChars && buffer) {
chunks.push({ start: startOffset, end: startOffset + buffer.length, text: buffer });
startOffset += buffer.length + 2; // account for the "\n\n" we split on
buffer = para;
} else {
buffer = tentative;
}
}
if (buffer) chunks.push({ start: startOffset, end: startOffset + buffer.length, text: buffer });
return chunks;
}
/**
* Answer a natural-language question by ranking the workspace's chunks.
*
* @param {object} args
* @param {string} args.question
* @param {Array<{path:string, content:string, mtimeMs?:number}>} args.files
* @param {number} [args.topK=5] number of chunks to return
* @param {number} [args.nowMs=Date.now()]
* @param {object} [args.engine] Optional SemanticEngine instance. When
* omitted, the default TF-idF engine is used. Pass a neural engine to
* swap in semantic embeddings.
* @returns {{question:string, chunks:Array<{filePath:string, snippet:string, score:number, mtimeMs:number}>}}
*/
async function ask({ question, files, topK = 5, nowMs = Date.now(), engine = null }) {
const cleaned = cleanQuestion(question);
if (!cleaned || !Array.isArray(files) || files.length === 0) {
return { question: String(question || ''), chunks: [] };
}
// Chunk the corpus up front — both default and neural engines rank at
// chunk granularity.
const chunkCorpus = [];
for (const file of files) {
if (!file || typeof file.content !== 'string') continue;
const chunks = chunkDocument(file.content);
for (const chunk of chunks) {
chunkCorpus.push({
path: `${file.path}#${chunk.start}`,
content: chunk.text,
offset: chunk.start,
mtimeMs: file.mtimeMs,
});
}
}
if (chunkCorpus.length === 0) {
return { question: String(question), chunks: [] };
}
// Resolve engine (default = tf-idf)
const eng = engine || SemanticEngine.defaultEngine();
let chunkHits;
if (eng.isNeural) {
// Neural: rank directly on the question against the chunk corpus.
chunkHits = await eng.rank(cleaned, chunkCorpus);
} else {
// TF-idF: broad doc pass first (caps the chunk corpus), then chunk rank.
const docHits = WorkspaceSearch.search({ query: cleaned, files, limit: 20, nowMs });
const docPaths = new Set(docHits.map((h) => h.filePath));
const filtered = chunkCorpus.filter((c) => {
const filePath = c.path.replace(/#\d+$/, '');
return docPaths.has(filePath);
});
chunkHits = WorkspaceSearch.search({
query: cleaned,
files: filtered.length > 0 ? filtered : chunkCorpus,
limit: topK,
nowMs,
});
}
// Translate the per-chunk hits back into the public shape.
const chunks = chunkHits.slice(0, topK).map((h) => {
const offsetMatch = /#(\d+)$/.exec(h.filePath);
const offset = offsetMatch ? Number(offsetMatch[1]) : h.offset || 0;
return {
filePath: h.filePath.replace(/#\d+$/, ''),
offset,
snippet: h.snippet,
score: h.score,
mtimeMs: chunkCorpus.find((c) => c.path === h.filePath)?.mtimeMs || h.mtimeMs || 0,
};
});
return { question: String(question), chunks };
}
module.exports = {
cleanQuestion,
chunkDocument,
ask,
QUESTION_WORDS,
};
+15 -9
View File
@@ -152,9 +152,7 @@ function buildLatexThemeHeader(themeId) {
// exotic minimal TeX install degrades by omission (pandoc still succeeds) // exotic minimal TeX install degrades by omission (pandoc still succeeds)
lines.push('\\usepackage{titlesec}'); lines.push('\\usepackage{titlesec}');
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}'); if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
const rule = p.sectionRule && p.ruleColor const rule = p.sectionRule && p.ruleColor ? '{\\color{mcthemerule}\\titlerule[0.6pt]}' : '';
? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
: '';
lines.push( lines.push(
`\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}` `\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}`
); );
@@ -167,7 +165,9 @@ function buildLatexThemeHeader(themeId) {
// Pandoc loads hyperref itself; color links via \hypersetup in the header // Pandoc loads hyperref itself; color links via \hypersetup in the header
lines.push('\\usepackage{etoolbox}'); lines.push('\\usepackage{etoolbox}');
lines.push('\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}'); lines.push(
'\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}'
);
return lines.join('\n') + '\n'; return lines.join('\n') + '\n';
} }
@@ -193,7 +193,16 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
if (!stylesFile) return false; if (!stylesFile) return false;
let xml = stylesFile.asText(); let xml = stylesFile.asText();
const headingIds = ['Title', 'Subtitle', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6']; const headingIds = [
'Title',
'Subtitle',
'Heading1',
'Heading2',
'Heading3',
'Heading4',
'Heading5',
'Heading6',
];
for (const styleId of headingIds) { for (const styleId of headingIds) {
// Match this style's <w:style …w:styleId="X">…</w:style> block (they // Match this style's <w:style …w:styleId="X">…</w:style> block (they
// never nest), then rewrite its rPr color/rFonts // never nest), then rewrite its rPr color/rFonts
@@ -234,10 +243,7 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
// Body font: patch the docDefaults rPrDefault (affects Normal everywhere) // Body font: patch the docDefaults rPrDefault (affects Normal everywhere)
if (d.bodyFont) { if (d.bodyFont) {
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`; const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
xml = xml.replace( xml = xml.replace(/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/, `$1${patch}`);
/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/,
`$1${patch}`
);
} }
zip.file('word/styles.xml', xml); zip.file('word/styles.xml', xml);
+3 -1
View File
@@ -160,7 +160,9 @@ async function convertToMarkdown(inputPath, options = {}) {
// Prefer the LAST informative stderr line: markitdown ends its // Prefer the LAST informative stderr line: markitdown ends its
// output with the actionable install hint ("pip install // output with the actionable install hint ("pip install
// 'markitdown[pdf]'"), while the first lines are a traceback. // 'markitdown[pdf]'"), while the first lines are a traceback.
const stderrLines = safe(stderr).split('\n').filter((l) => l.trim().length > 0); const stderrLines = safe(stderr)
.split('\n')
.filter((l) => l.trim().length > 0);
const detail = const detail =
stderrLines[stderrLines.length - 1] || stderrLines[stderrLines.length - 1] ||
safe(error.message).split('\n')[0] || safe(error.message).split('\n')[0] ||
+1 -4
View File
@@ -445,10 +445,7 @@ async function loadPdfjs() {
// Windows path (C:\...\standard_fonts\) fails that check and breaks // Windows path (C:\...\standard_fonts\) fails that check and breaks
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL. // extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
function getStandardFontDataUrl() { function getStandardFontDataUrl() {
const dir = path.join( const dir = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts');
path.dirname(require.resolve('pdfjs-dist/package.json')),
'standard_fonts'
);
const url = require('url').pathToFileURL(dir); const url = require('url').pathToFileURL(dir);
return url.href.endsWith('/') ? url.href : url.href + '/'; return url.href.endsWith('/') ? url.href : url.href + '/';
} }
+168
View File
@@ -0,0 +1,168 @@
/**
* Semantic engine — pluggable interface for DocQA's ranking algorithm.
*
* Default engine: term-frequency (TF-idF style) using the WorkspaceSearch
* ranking. Always present, no install, runs in milliseconds.
*
* Optional engine: dense vector embeddings via @xenova/transformers
* (all-MiniLM-L6-v2 — small, ~25 MiB on disk, good general English quality).
* Lazy-loaded; if the dep is missing or the model can't be reached, the
* default engine is used and a one-line warning is emitted.
*
* The DocQA.ask() function takes an `engine` arg (optional). When omitted,
* the default is used. When a neural engine is provided, DocQA will call
* engine.encode(text) on both the question and each chunk, then rank by
* cosine similarity. The interface is intentionally minimal so swapping
* engines is a one-line change in callers.
*
* Activation (manual):
* 1. `npm install @xenova/transformers` (heavy; ~50 MiB with deps)
* 2. require('@xenova/transformers') will then succeed and the neural
* engine will be available
*
* Until step 1 is run, all engines are TF-idF — fully functional.
*
* @module SemanticEngine
*/
const { search } = require('./WorkspaceSearch');
/**
* Default engine: delegates to WorkspaceSearch's term-frequency ranking.
* No setup, no IO, always available.
*/
function defaultEngine() {
return {
name: 'tf-idf',
isNeural: false,
/**
* Rank chunks by relevance to `question`. Returns the same shape as
* WorkspaceSearch.search(): [{filePath, snippet, score, ...}]
*
* @param {string} question
* @param {Array<{path:string, content:string, mtimeMs?:number}>} chunks
* @returns {Promise<Array>}
*/
async rank(question, chunks) {
// WorkspaceSearch.search expects raw "files" — each chunk is a file.
// We pass them through and translate the per-chunk paths back.
const r = search({ query: question, files: chunks, limit: chunks.length });
return r;
},
};
}
/**
* Neural engine (when @xenova/transformers is installed).
*
* Uses all-MiniLM-L6-v2 (384-dim, ~25 MiB on first use). Lazy-loads on first
* call so the cold-start of the app isn't slowed down. Embeddings are
* cached per-question text in a Map<string, Float32Array> to avoid
* recomputation within a session.
*
* Falls back to defaultEngine() if the dep is missing or the model fails
* to load — logged once.
*
* @returns {Promise<object>} engine with rank(question, chunks)
*/
async function neuralEngine(opts = {}) {
const modelName = typeof opts.model === 'string' ? opts.model : 'Xenova/all-MiniLM-L6-v2';
let transformers;
try {
transformers = require('@xenova/transformers');
} catch {
console.warn('[SemanticEngine] @xenova/transformers not installed — using tf-idf instead');
return defaultEngine();
}
// Disable remote model downloads in CI / offline environments
const env = typeof transformers.env !== 'undefined' ? transformers.env : null;
if (env && opts.allowRemote === false) {
env.allowRemoteModels = false;
env.allowLocalModels = true;
}
let pipeline;
try {
pipeline = await transformers.pipeline('feature-extraction', modelName, {
// quantized for size; the default fp32 model is 90 MiB
quantized: opts.quantized !== false,
});
} catch (err) {
console.warn(
`[SemanticEngine] Failed to load ${modelName} (${err && err.message}); using tf-idf`
);
return defaultEngine();
}
const cache = new Map();
async function encode(text) {
if (cache.has(text)) return cache.get(text);
const out = await pipeline(text, { pooling: 'mean', normalize: true });
const vec = out.data instanceof Float32Array ? out.data : Float32Array.from(out.data);
cache.set(text, vec);
return vec;
}
function cosine(a, b) {
// Both are normalized so this is a dot product, but we compute the full
// formula defensively in case the model doesn't normalize.
let dot = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) dot += a[i] * b[i];
return dot;
}
return {
name: `transformers:${modelName}`,
isNeural: true,
async rank(question, chunks) {
const qVec = await encode(question);
const scored = [];
for (const chunk of chunks) {
let cVec;
try {
cVec = await encode(chunk.content.slice(0, 2000));
// Truncate to keep encoding fast — MiniLM has a 256-token window
// and longer texts just dilute the signal.
} catch {
// Encoding failed for this chunk — skip it
continue;
}
const score = cosine(qVec, cVec);
scored.push({
filePath: chunk.path,
snippet: (chunk.content || '').slice(0, 200).replace(/\s+/g, ' ').trim() + '…',
score,
mtimeMs: chunk.mtimeMs,
offset: chunk.offset || 0,
});
}
scored.sort((a, b) => b.score - a.score);
return scored;
},
};
}
/**
* Get an engine by name. Unknown names fall back to defaultEngine().
*
* @param {string} [name='tf-idf']
* @param {object} [opts]
* @returns {Promise<object>} engine
*/
async function getEngine(name = 'tf-idf', opts = {}) {
if (name === 'tf-idf') return defaultEngine();
if (name === 'transformers' || name.startsWith('transformers:')) {
return neuralEngine(opts);
}
return defaultEngine();
}
module.exports = {
defaultEngine,
neuralEngine,
getEngine,
};
+79
View File
@@ -0,0 +1,79 @@
/**
* Editor Theme Registry bootstrap — registers all shipped themes at startup.
*
* Importing this module has the side effect of populating the registry.
* `src/main.js` requires this once during startup. Test files require it
* after `ThemeRegistry.clear()` to assert the snapshot.
*
* Theme ids are kebab-case. Categories: 'light' | 'dark' | 'high-contrast'
* | 'seasonal'. `isDark` is independent of category — see spec note on
* winter-is-coming-light (light-category, dark-not-required) and the future
* seasonal-with-dark possibility.
*
* @module ThemeRegistry.bootstrap
*/
const ThemeRegistry = require('./ThemeRegistry');
const THEMES = [
// ---- 25 existing menu themes (refactor — no behavioural change) ──
// Light
{ id: 'atomonelight', label: 'Atom One Light (Default)', category: 'light', isDark: false },
{ id: 'github', label: 'GitHub Light', category: 'light', isDark: false },
{ id: 'light', label: 'Light', category: 'light', isDark: false },
{ id: 'solarized', label: 'Solarized Light', category: 'light', isDark: false },
{ id: 'gruvbox-light', label: 'Gruvbox Light', category: 'light', isDark: false },
{ id: 'ayu-light', label: 'Ayu Light', category: 'light', isDark: false },
{ id: 'sepia', label: 'Sepia', category: 'light', isDark: false },
{ id: 'paper', label: 'Paper', category: 'light', isDark: false },
{ id: 'rosepine-dawn', label: 'Rose Pine Dawn', category: 'light', isDark: false },
{ id: 'concrete-light', label: 'Concrete Light', category: 'light', isDark: false },
// Dark
{ id: 'dark', label: 'Dark', category: 'dark', isDark: true },
{ id: 'onedark', label: 'One Dark', category: 'dark', isDark: true },
{ id: 'dracula', label: 'Dracula', category: 'dark', isDark: true },
{ id: 'nord', label: 'Nord', category: 'dark', isDark: true },
{ id: 'monokai', label: 'Monokai', category: 'dark', isDark: true },
{ id: 'material', label: 'Material', category: 'dark', isDark: true },
{ id: 'gruvbox-dark', label: 'Gruvbox Dark', category: 'dark', isDark: true },
{ id: 'tokyonight', label: 'Tokyo Night', category: 'dark', isDark: true },
{ id: 'palenight', label: 'Palenight', category: 'dark', isDark: true },
{ id: 'ayu-dark', label: 'Ayu Dark', category: 'dark', isDark: true },
{ id: 'ayu-mirage', label: 'Ayu Mirage', category: 'dark', isDark: true },
{ id: 'oceanic-next', label: 'Oceanic Next', category: 'dark', isDark: true },
{ id: 'cobalt2', label: 'Cobalt2', category: 'dark', isDark: true },
{ id: 'concrete-dark', label: 'Concrete Dark', category: 'dark', isDark: true },
{ id: 'concrete-warm', label: 'Concrete Warm', category: 'dark', isDark: true },
// ---- 12 new themes ──
// Catppuccin (4)
{ id: 'catppuccin-latte', label: 'Catppuccin Latte', category: 'light', isDark: false },
{ id: 'catppuccin-frappe', label: 'Catppuccin Frappé', category: 'dark', isDark: true },
{ id: 'catppuccin-macchiato', label: 'Catppuccin Macchiato', category: 'dark', isDark: true },
{ id: 'catppuccin-mocha', label: 'Catppuccin Mocha', category: 'dark', isDark: true },
// One Light + Tokyo Night Storm
{ id: 'one-light', label: 'One Light', category: 'light', isDark: false },
{ id: 'tokyo-night-storm', label: 'Tokyo Night Storm', category: 'dark', isDark: true },
// Synthwave / Outrun
{ id: 'synthwave-84', label: "Synthwave '84", category: 'dark', isDark: true },
{ id: 'outrun', label: 'Outrun', category: 'dark', isDark: true },
// Winter is Coming (light + dark)
{
id: 'winter-is-coming-light',
label: 'Winter is Coming (Light)',
category: 'light',
isDark: false,
},
{ id: 'winter-is-coming-dark', label: 'Winter is Coming (Dark)', category: 'dark', isDark: true },
// Solarized HC + Spring seasonal
{
id: 'solarized-dark-hc',
label: 'Solarized Dark (High Contrast)',
category: 'high-contrast',
isDark: true,
},
{ id: 'spring-light', label: 'Spring Light', category: 'seasonal', isDark: false },
];
for (const t of THEMES) ThemeRegistry.register(t);
module.exports = { THEMES };
+108
View File
@@ -0,0 +1,108 @@
/**
* Editor Theme Registry — single source of truth for installed editor themes.
*
* Pure module: no Electron, no IO, no globals beyond a module-scoped Map.
* The bootstrap (`ThemeRegistry.bootstrap.js`) calls `register()` at startup;
* `main.js` menu builder calls `list()` + `categories()`; `setTheme()`
* validation calls `get()`.
*
* Theme shape:
* { id, label, category: 'light'|'dark'|'high-contrast'|'seasonal', isDark }
*
* Ids are kebab-case lowercase. Categories are independent of `isDark` —
* e.g. `winter-is-coming-light` has `isDark: false` and `category: 'light'`,
* but a future seasonal theme might pair `category: 'seasonal'` with
* `isDark: true`. Filtering by category is the public contract; `isDark`
* is metadata for the renderer.
*
* @module ThemeRegistry
*/
const VALID_CATEGORIES = new Set(['light', 'dark', 'high-contrast', 'seasonal']);
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const themes = new Map(); // id → theme
function validate(theme) {
if (!theme || typeof theme !== 'object') throw new Error('theme must be an object');
if (typeof theme.id !== 'string' || !theme.id) throw new Error('theme is missing id');
if (!ID_RE.test(theme.id)) {
throw new Error(`theme id must be kebab-case lowercase: got "${theme.id}"`);
}
if (typeof theme.label !== 'string' || !theme.label) {
throw new Error(`theme "${theme.id}" is missing label`);
}
if (!('category' in theme) || theme.category === undefined || theme.category === null) {
throw new Error(`theme "${theme.id}" is missing category`);
}
if (!VALID_CATEGORIES.has(theme.category)) {
throw new Error(
`theme "${theme.id}" has invalid category "${theme.category}" (allowed: ${[...VALID_CATEGORIES].join(', ')})`
);
}
if (typeof theme.isDark !== 'boolean') {
throw new Error(`theme "${theme.id}" is missing isDark boolean`);
}
}
function register(theme) {
validate(theme);
if (themes.has(theme.id)) {
throw new Error(`duplicate theme id: ${theme.id}`);
}
themes.set(theme.id, {
id: theme.id,
label: theme.label,
category: theme.category,
isDark: theme.isDark,
});
return theme;
}
function unregister(id) {
return themes.delete(id);
}
function list() {
return [...themes.values()];
}
function get(id) {
return themes.get(id) || null;
}
function categories() {
const seen = new Set();
const out = [];
for (const t of themes.values()) {
if (!seen.has(t.category)) {
seen.add(t.category);
out.push(t.category);
}
}
return out;
}
function lightThemes() {
return list().filter((t) => t.category === 'light');
}
function darkThemes() {
return list().filter((t) => t.category === 'dark');
}
/** Test-only: empty the registry between tests. */
function clear() {
themes.clear();
}
module.exports = {
register,
unregister,
list,
get,
categories,
lightThemes,
darkThemes,
clear,
};
+161
View File
@@ -0,0 +1,161 @@
/**
* URL title extraction.
*
* Given a URL, fetch the page and return its <title> for smart-paste: paste
* a link, get a markdown link with the page's title as the label. Pure
* module — main.js wires the actual fetch via the injectable `fetch` arg
* (defaults to globalThis.fetch in production; tests pass a stub).
*
* Constraints:
* - HTML only (Content-Type starts with text/html)
* - ≤2 MiB body (so a 4GB tarball link doesn't OOM us)
* - 5-second total timeout (AbortController)
* - Title decoded as ISO-8859-1 → UTF-8 (browser-grade heuristic; HTML5
* charset wins when present)
* - Stripped of newlines/extra whitespace — labels must be one logical line
* - Capped at 200 chars — past that the label becomes ugly in markdown
*
* @module UrlTitle
*/
/** Match the first <title>…</title> in an HTML body. Case-insensitive. */
function extractTitleFromHtml(html) {
if (typeof html !== 'string') return null;
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
if (!match) return null;
return decodeTitle(match[1]);
}
/**
* Decode an HTML <title> value to readable text.
* - decode named/numeric entities (browser-grade for common ones)
* - collapse internal whitespace (browsers do this when computing the
* document.title property)
* - trim
*/
function decodeTitle(raw) {
if (typeof raw !== 'string') return '';
// Named + numeric entities we care about. The full HTML5 set is ~250
// entries; the ones below cover the common ones for English pages.
const entities = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",
'&nbsp;': ' ',
'&mdash;': '—',
'&ndash;': '–',
'&hellip;': '…',
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',
};
let s = raw;
for (const [ent, ch] of Object.entries(entities)) {
s = s.split(ent).join(ch);
}
// Numeric entities: &#NN; or &#xHH;
s = s.replace(/&#(\d+);/g, (_, n) => {
const code = Number(n);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '';
});
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => {
const code = parseInt(n, 16);
return Number.isFinite(code) ? String.fromCodePoint(code) : '';
});
// Don't strip <...>-looking strings here: an entity like &lt;b&gt; decodes
// to a literal "<b>" which would otherwise get eaten. Titles with embedded
// tags in the source HTML are vanishingly rare and we can pass them through
// as text. Collapse internal whitespace and trim instead.
s = s.replace(/\s+/g, ' ').trim();
return s;
}
/** Is the URL fetchable (http/https only)? Rejects file://, javascript:, data:, … */
function isHttpUrl(value) {
if (typeof value !== 'string') return false;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
/**
* Fetch a URL and return its <title>. Returns null when:
* - the URL is not http(s)
* - the response isn't HTML
* - the body is larger than maxBytes (default 2 MiB)
* - the request times out (default 5 s) or otherwise fails
* - the response has no <title>
*
* @param {object} args
* @param {string} args.url
* @param {number} [args.timeoutMs=5000]
* @param {number} [args.maxBytes=2 * 1024 * 1024]
* @param {typeof fetch} [args.fetch] injectable for tests
* @returns {Promise<{url:string, title:string} | null>}
*/
async function fetchTitle({
url,
timeoutMs = 5000,
maxBytes = 2 * 1024 * 1024,
fetch = globalThis.fetch,
}) {
if (!isHttpUrl(url) || typeof fetch !== 'function') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
redirect: 'follow',
signal: controller.signal,
headers: { Accept: 'text/html,application/xhtml+xml' },
});
if (!res || !res.ok) return null;
const ctype = res.headers && res.headers.get ? res.headers.get('content-type') || '' : '';
if (!ctype.toLowerCase().includes('text/html')) return null;
// Read up to maxBytes + 1 so we can detect overflow
const reader =
res.body && typeof res.body.getReader === 'function' ? res.body.getReader() : null;
let body = '';
if (reader) {
while (true) {
const { value, done } = await reader.read();
if (done) break;
body += new TextDecoder('utf-8', { fatal: false }).decode(value, { stream: true });
if (body.length > maxBytes) {
try {
await reader.cancel();
} catch {
/* noop */
}
return null;
}
}
body += new TextDecoder('utf-8', { fatal: false }).decode();
} else {
body = await res.text();
if (body.length > maxBytes) return null;
}
const title = extractTitleFromHtml(body);
if (!title) return null;
const capped = title.length > 200 ? title.slice(0, 200).trim() + '…' : title;
return { url, title: capped };
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
module.exports = {
extractTitleFromHtml,
decodeTitle,
isHttpUrl,
fetchTitle,
};
+203
View File
@@ -0,0 +1,203 @@
/**
* Workspace content search — the simplest useful thing that scales.
*
* Given a list of file paths and a query, returns ranked matches across
* them. Designed for personal-workspace sizes (hundreds, maybe thousands of
* notes) — runs synchronously on the main process with injectable IO so it
* can be unit-tested without touching the filesystem.
*
* Query grammar (all optional, combinable):
* - Bare words → term matches (case-insensitive substring)
* - Words prefixed with `#` → require a matching `#tag` in the doc
* - Words prefixed with `@` → require a matching `[[wikilink]]` target
* - Quoted phrases → require the exact substring
*
* Scoring:
* - Each matched term adds +1 per occurrence (capped at 5/term to dampen
* repetition spam)
* - Each tag hit (`#foo`) or wikilink hit (`[[foo]]`) adds +3 if the query
* asked for it (facets weigh more than prose)
* - Recent edits (mtime within 7 days) get +0.5 to nudge "what I was
* working on yesterday" upward
*
* Returns [{ filePath, score, snippet, matchedTerms, matchedTags, matchedLinks }]
* sorted by score desc, filePath asc as tiebreaker.
*
* @module WorkspaceSearch
*/
/** Tokenize a query into a structured form. Pure. */
function parseQuery(raw) {
if (typeof raw !== 'string') return { terms: [], phrases: [], tags: [], links: [] };
const terms = [];
const phrases = [];
const tags = [];
const links = [];
// Phrases first — match "double quoted text" as a unit
const phraseRe = /"([^"]+)"/g;
let remaining = raw.replace(phraseRe, (_, inner) => {
if (inner.trim()) phrases.push(inner.toLowerCase());
return ' ';
});
// Tags and links: #foo, @bar
const facetRe = /[#@]([A-Za-z0-9_-]+)/g;
remaining = remaining.replace(facetRe, (m, name) => {
if (m.startsWith('#')) tags.push(name.toLowerCase());
else links.push(name.toLowerCase());
return ' ';
});
// Bare words (≥2 chars to avoid noise)
for (const w of remaining.split(/\s+/)) {
if (w.length >= 2) terms.push(w.toLowerCase());
}
return { terms, phrases, tags, links };
}
/** Extract a one-line snippet around the first match for `term`. Pure. */
function snippetAround(content, term, radius = 60) {
const haystack = content.toLowerCase();
const idx = haystack.indexOf(term);
if (idx < 0) return '';
const start = Math.max(0, idx - radius);
const end = Math.min(content.length, idx + term.length + radius);
let s = content.slice(start, end).replace(/\s+/g, ' ').trim();
if (start > 0) s = '…' + s;
if (end < content.length) s = s + '…';
return s;
}
/** Count occurrences of `needle` (case-insensitive substring) in `haystack`. */
function countOccurrences(haystack, needle) {
if (!needle) return 0;
const hay = haystack.toLowerCase();
const ndl = needle.toLowerCase();
let count = 0;
let idx = 0;
while ((idx = hay.indexOf(ndl, idx)) !== -1) {
count++;
idx += ndl.length;
}
return count;
}
/** Does the content contain the `#tag`? Looks for word-boundary `#tag`. */
function hasTag(content, tag) {
const re = new RegExp(`(^|\\s)#${escapeRegex(tag)}\\b`, 'i');
return re.test(content);
}
/** Does the content contain `[[wikilink]]` or `[[wikilink|alias]]`? */
function hasWikilink(content, link) {
const re = new RegExp(`\\[\\[${escapeRegex(link)}(\\|[^\\]]+)?\\]\\]`, 'i');
return re.test(content);
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Run a parsed query against one document's content. Returns
* { score, matchedTerms, matchedTags, matchedLinks, snippet } or null if
* nothing matched (so the caller can filter cheaply).
*/
function scoreDocument({ content, parsed, mtimeMs = 0, nowMs = Date.now() }) {
let score = 0;
const matchedTerms = [];
const matchedTags = [];
const matchedLinks = [];
const lower = content.toLowerCase();
for (const term of parsed.terms) {
const n = countOccurrences(content, term);
if (n > 0) {
score += Math.min(n, 5);
matchedTerms.push(term);
}
}
for (const phrase of parsed.phrases) {
if (lower.includes(phrase)) {
score += 3; // phrase hits weigh more
matchedTerms.push(phrase);
}
}
for (const tag of parsed.tags) {
if (hasTag(content, tag)) {
score += 3;
matchedTags.push(tag);
}
}
for (const link of parsed.links) {
if (hasWikilink(content, link)) {
score += 3;
matchedLinks.push(link);
}
}
if (score === 0) return null;
// Recency nudge: edits within the last 7 days
const ageDays = (nowMs - mtimeMs) / (24 * 60 * 60 * 1000);
if (Number.isFinite(ageDays) && ageDays >= 0 && ageDays <= 7) {
score += 0.5 * (1 - ageDays / 7);
}
// Build a snippet around the first matched term (or phrase/tag) for the UI
let snippet = '';
const firstTerm = matchedTerms[0] || (parsed.tags[0] ? `#${parsed.tags[0]}` : null);
if (firstTerm) {
snippet = snippetAround(content, firstTerm);
} else {
snippet = content.slice(0, 120).replace(/\s+/g, ' ').trim() + (content.length > 120 ? '…' : '');
}
return { score, matchedTerms, matchedTags, matchedLinks, snippet };
}
/**
* Search across many files. Files whose content matches the query are
* scored and returned sorted by score.
*
* @param {object} args
* @param {string} args.query raw query string
* @param {Array<{path:string, content:string, mtimeMs?:number}>} args.files
* @param {number} [args.limit=50]
* @returns {Array<{filePath:string, score:number, snippet:string, matchedTerms:string[], matchedTags:string[], matchedLinks:string[]}>}
*/
function search({ query, files, limit = 50, nowMs = Date.now() }) {
const parsed = parseQuery(query);
// Nothing to search for — short-circuit so callers don't pay the file loop
if (
parsed.terms.length === 0 &&
parsed.phrases.length === 0 &&
parsed.tags.length === 0 &&
parsed.links.length === 0
) {
return [];
}
const out = [];
for (const file of files) {
if (!file || typeof file.content !== 'string') continue;
const r = scoreDocument({ content: file.content, parsed, mtimeMs: file.mtimeMs, nowMs });
if (!r) continue;
out.push({ filePath: file.path, ...r });
}
out.sort(
(a, b) => b.score - a.score || (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0)
);
return out.slice(0, limit);
}
module.exports = {
parseQuery,
snippetAround,
countOccurrences,
hasTag,
hasWikilink,
scoreDocument,
search,
};
+75
View File
@@ -0,0 +1,75 @@
/**
* Auto-updater wiring for electron-updater.
*
* Wraps electron-updater's autoUpdater so the main process has one place
* to:
* - start a check on app launch
* - forward events to the renderer over 'updates:status' IPC
* - accept "install now" / "check now" requests from the renderer
*
* Safe to call in dev: when no app-update.yml is packaged (which is the
* case for `npm start` / electron .), electron-updater surfaces an
* "app-update.yml not found" error which we swallow and log. The renderer
* still gets a `not-available` event so the UI can show "you're up to
* date" instead of a spinning indicator that never resolves.
*
* @module auto-updater
*/
/**
* @param {object} deps
* @param {(channel:string, payload?:any) => void} deps.send - forwards
* status events to the BrowserWindow's webContents
* @param {() => boolean} [deps.isDev] - dev gate (skip check in dev)
*/
function setupAutoUpdater({ send, isDev }) {
// Lazy-load — electron-updater touches `app` at import time, which fails
// in test environments where the app isn't bootstrapped.
const { autoUpdater } = require('electron-updater');
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// Forward every event to the renderer with a typed payload.
autoUpdater.on('checking-for-update', () => send('updates:status', { state: 'checking' }));
autoUpdater.on('update-available', (info) =>
send('updates:status', { state: 'available', version: info?.version })
);
autoUpdater.on('update-not-available', (info) =>
send('updates:status', { state: 'not-available', version: info?.version })
);
autoUpdater.on('download-progress', (p) =>
send('updates:status', { state: 'downloading', percent: Math.round(p.percent || 0) })
);
autoUpdater.on('update-downloaded', (info) =>
send('updates:status', { state: 'downloaded', version: info?.version })
);
autoUpdater.on('error', (err) =>
send('updates:status', {
state: 'error',
message: err && err.message ? err.message : 'Update check failed.',
})
);
function check() {
if (typeof isDev === 'function' && isDev()) return Promise.resolve(null);
return autoUpdater.checkForUpdates().catch((err) => {
// Dev runs surface "app-update.yml not found" — treat that as benign.
const msg = err && err.message ? err.message : String(err);
if (msg.includes('app-update.yml') || msg.includes('Cannot find')) {
send('updates:status', { state: 'not-available' });
return null;
}
send('updates:status', { state: 'error', message: msg });
return null;
});
}
function install() {
autoUpdater.quitAndInstall();
}
return { check, install };
}
module.exports = { setupAutoUpdater };
+50
View File
@@ -0,0 +1,50 @@
/**
* Editor Theme menu builder — converts ThemeRegistry output into Electron
* MenuItem[] suitable for the View → Theme submenu.
*
* Pure: takes `setTheme` + `getCurrentThemeId` as injected dependencies so
* the builder never touches electron-store or `mainWindow`. Tested in
* isolation under `@jest-environment node`.
*
* Menu shape (matches the previous hardcoded block in src/main.js:1137-1245):
* [item, item, …, { type: 'separator' }, item, item, …, separator, …]
* grouped by category in registry order, with separators between non-empty
* categories. Each theme item is a radio-style MenuItem so Electron shows a
* checkmark next to the active theme.
*
* @module themeMenuBuilder
*/
const ThemeRegistry = require('./ThemeRegistry');
/**
* @param {object} deps
* @param {(id: string) => void} deps.setTheme
* @param {() => string} deps.getCurrentThemeId
* @returns {Array<object>} Electron MenuItemTemplate[]
*/
function buildThemeMenu({ setTheme, getCurrentThemeId }) {
const currentId = getCurrentThemeId();
const items = [];
const cats = ThemeRegistry.categories();
let nonEmptySeen = 0;
for (const cat of cats) {
const inCat = ThemeRegistry.list().filter((t) => t.category === cat);
if (inCat.length === 0) continue;
if (nonEmptySeen > 0) items.push({ type: 'separator' });
nonEmptySeen++;
for (const t of inCat) {
items.push({
label: t.label,
type: 'radio',
checked: t.id === currentId,
click: () => setTheme(t.id),
});
}
}
return items;
}
module.exports = { buildThemeMenu };
@@ -158,7 +158,9 @@ function askForProjectName() {
overlay.remove(); overlay.remove();
resolve(value); resolve(value);
}; };
overlay.querySelector('[data-role="ok"]').addEventListener('click', () => done(input.value.trim() || null)); overlay
.querySelector('[data-role="ok"]')
.addEventListener('click', () => done(input.value.trim() || null));
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null)); overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
overlay.addEventListener('click', (event) => { overlay.addEventListener('click', (event) => {
if (event.target === overlay) done(null); if (event.target === overlay) done(null);
+134 -4
View File
@@ -44,6 +44,17 @@ const ALLOWED_SEND_CHANNELS = [
'ai-assistant:complete', 'ai-assistant:complete',
'ai-assistant:status', 'ai-assistant:status',
// Inline AI assist confirm helpers
'ai-assistant:confirm-info',
// v4.13.0 — Inline AI assist streaming (Cmd+K in editor)
'ai-assist-stream:start',
'ai-assist-stream:cancel',
// v4.13.0 — auto-update (electron-updater)
'updates:check',
'updates:install',
// Batch conversion // Batch conversion
'batch-convert', 'batch-convert',
'select-folder', 'select-folder',
@@ -98,6 +109,21 @@ const ALLOWED_SEND_CHANNELS = [
// ASCII generator (separate window) // ASCII generator (separate window)
'open-ascii-generator', 'open-ascii-generator',
// Flowchart generator (standalone window — v4.9.6)
'open-flowchart-generator',
// Flowchart generator: dialog-based file save (v4.12.0)
'save-text-file',
'open-text-file-dialog',
// ASCII art generator (standalone window — invoke channels)
'ascii:generate',
'ascii:list-fonts',
'ascii:get-font-meta',
'ascii:save',
'ascii:copy',
'ascii:last-font',
// Table generator (separate window) // Table generator (separate window)
'open-table-generator', 'open-table-generator',
@@ -175,11 +201,40 @@ const ALLOWED_SEND_CHANNELS = [
// Quick Note scratchpad // Quick Note scratchpad
'quick-note:save', 'quick-note:save',
// Quick-switcher (Cmd+P workspace file picker)
'quick-switcher:list-files',
'recent-files:get',
// Document version history // Document version history
'version-history:list', 'version-history:list',
'version-history:read', 'version-history:read',
'version-history:save', 'version-history:save',
'version-history:delete', 'version-history:delete',
// Autosave + crash-recovery buffer
'autosave:write',
'autosave:read',
'autosave:clear',
'autosave:list',
// Daily notes (one file per local date)
'daily-notes:open-today',
'daily-notes:list',
// Daily-note template gallery
'daily-templates:list',
'daily-templates:save',
'daily-templates:delete',
'daily-templates:apply',
// Workspace content search (tag/wikilink-aware)
'workspace-search:query',
// Doc-aware Q&A (chunk-level ranking over the workspace)
'doc-qa:ask',
// Smart-paste: URL → page title
'url-title:fetch',
]; ];
const ALLOWED_RECEIVE_CHANNELS = [ const ALLOWED_RECEIVE_CHANNELS = [
@@ -266,10 +321,6 @@ const ALLOWED_RECEIVE_CHANNELS = [
'pdf-operation-error', 'pdf-operation-error',
'pdf-batch-complete', 'pdf-batch-complete',
// ASCII Art Generator
'show-ascii-generator-window',
'show-ascii-generator',
// Table Generator // Table Generator
'show-table-generator-window', 'show-table-generator-window',
@@ -292,6 +343,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
'toggle-sidebar-panel', 'toggle-sidebar-panel',
'toggle-bottom-panel', 'toggle-bottom-panel',
// v4.13.0 — quick-switcher overlay trigger
'show-quick-switcher',
// v4.13.0 — auto-update status events from main
'updates:status',
// Plugin export formats // Plugin export formats
'run-plugin-export-format', 'run-plugin-export-format',
]; ];
@@ -484,7 +541,80 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Generator Windows // Generator Windows
generators: { generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'), openAscii: () => ipcRenderer.send('open-ascii-generator'),
openFlowchart: () => ipcRenderer.send('open-flowchart-generator'),
openTable: () => ipcRenderer.send('open-table-generator'), openTable: () => ipcRenderer.send('open-table-generator'),
ascii: {
listFonts: () => ipcRenderer.invoke('ascii:list-fonts'),
getFontMeta: (id) => ipcRenderer.invoke('ascii:get-font-meta', id),
generate: (args) => ipcRenderer.invoke('ascii:generate', args),
copy: (text) => ipcRenderer.invoke('ascii:copy', text),
save: (args) => ipcRenderer.invoke('ascii:save', args),
lastFont: (font) => ipcRenderer.invoke('ascii:last-font', { font }),
},
},
// v4.9.6 — Flowchart Generator standalone window's IO bridge. Reuses the
// existing thin text-file IPC handlers (get-user-data-path, read-text-file,
// write-text-file) which sandbox writes to <userData>. Insert at cursor
// sends the Mermaid-fenced source through the existing 'insert-content'
// channel which the renderer.js sidebar panel also uses.
//
// v4.12.0 — `saveFile` opens a system Save dialog and writes the content
// to a user-chosen path. Bypasses the userData sandbox (user can save
// anywhere) — the dialog enforces the destination.
flowchart: {
getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'),
readFile: (p) => ipcRenderer.invoke('read-text-file', p),
writeFile: (p, content) => ipcRenderer.invoke('write-text-file', { path: p, content }),
saveFile: (content, defaultName) =>
ipcRenderer.invoke('save-text-file', { content, defaultName }),
// v4.13.0 — binary export (PNG / JPG / Visio / etc.). `data` is a
// base64 data URL produced by the renderer's SVG rasteriser; the main
// process strips the `data:...;base64,` prefix and writes the raw bytes.
saveBinary: (data, defaultName, filters) =>
ipcRenderer.invoke('save-binary-file', { data, defaultName, filters }),
// v4.13.0 — Visio .vsdx export. Renderer sends the current graph JSON;
// main process generates the OOXML zip and writes it.
exportVsdx: (graph) => ipcRenderer.invoke('export-vsdx', { graph }),
openFile: () => ipcRenderer.invoke('open-text-file-dialog'),
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
},
// v4.13.0 — Quick-switcher workspace file listing for the Cmd+P overlay.
// Returns [{ path, name }] sorted by directory walk order. The renderer's
// fuzzy matcher (src/quick-switcher/fuzzy-matcher.js) does the ranking.
quickSwitcher: {
listFiles: (dir, options) => ipcRenderer.invoke('quick-switcher:list-files', { dir, options }),
getRecentFiles: () => ipcRenderer.invoke('recent-files:get'),
},
// v4.13.0 — Inline AI assist streaming (Cmd+K on selected text).
// Caller passes {requestId, request}; main streams chunks via
// onChunk/Done/Error listeners. Callers MUST register listeners before
// calling start(), because the first chunk can fire on the next tick.
aiAssist: {
start: (requestId, request) =>
ipcRenderer.send('ai-assist-stream:start', { requestId, request }),
cancel: (requestId) => ipcRenderer.send('ai-assist-stream:cancel', { requestId }),
onChunk: (cb) => ipcRenderer.on('ai-assist-stream:chunk', (_e, p) => cb(p)),
onDone: (cb) => ipcRenderer.on('ai-assist-stream:done', (_e, p) => cb(p)),
onError: (cb) => ipcRenderer.on('ai-assist-stream:error', (_e, p) => cb(p)),
},
// v4.13.0 — auto-update controls. Check-now asks main to poll
// GitHub releases; install-now triggers quitAndInstall. Status events
// arrive via the generic on('updates:status', cb).
updates: {
check: () => ipcRenderer.invoke('updates:check'),
install: () => ipcRenderer.invoke('updates:install'),
onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)),
},
// v4.13.0 — Inline AI first-use confirmation. Returns a small
// user-safe payload (no keys) describing what the AI request will do,
// for the renderer to surface in a confirm dialog.
aiAssistant: {
confirmInfo: () => ipcRenderer.invoke('ai-assistant:confirm-info'),
}, },
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
+200
View File
@@ -0,0 +1,200 @@
/**
* Quick-switcher fuzzy matcher.
*
* Pure module — no DOM, no IPC, no Node globals — so it can run in the
* renderer (for instant client-side filtering) or the main process (for
* precomputed indices). Tested under @jest-environment node.
*
* Scoring tiers:
* exact match → 1.00 (query === target, case-insensitive)
* stem match → 1.00 (query === target without first extension)
* prefix match → 0.80
* subsequence match → 0.50 + bonuses, capped at 0.79
* no match → 0.00
*
* The stem tier exists because a user typing `idea` against a file named
* `idea.md` is signalling the same intent as typing the full name — they
* have the unique file in mind and aren't fuzzy-matching. Treating it as
* 1.0 (rather than 0.8 prefix) means recent/open-tabs boosts work as
* expected: 1.0 × 2.0 × 1.5 = 3.0 for an exact, open, recent file.
*
* Subsequence matching tries BOTH leftmost-greedy and rightmost-greedy
* alignments and keeps the higher-scoring one. Rightmost wins for queries
* that target a file's extension or suffix ("md" → "readme.md"); leftmost
* wins for queries that target a leading prefix ("re" → "readme.md").
*
* Bonuses (each +0.05, capped):
* - word-boundary hit: previous char is one of / - _ space .
* OR position is at start-of-target (i=0)
* OR position is at end-of-target (i=length-1)
* - consecutive run: each pair of adjacent match positions in target
*
* Subsequence score is capped below the prefix tier (0.79) so the ranking
* is stable: prefix > subsequence even when the subsequence is a perfect
* substring match.
*
* @module fuzzy-matcher
*/
const SEPARATORS = /[\/\-_. ]/;
const SUBSEQ_CAP = 0.79;
const BOUNDARY_BONUS = 0.05;
const CONSECUTIVE_BONUS = 0.1;
/**
* Score `query` against `target` (typically a filename basename).
*
* @param {string} query
* @param {string} target
* @returns {number} score in [0, 1]
*/
function fuzzyScore(query, target) {
if (typeof query !== 'string' || typeof target !== 'string') return 0;
if (query.length === 0 || target.length === 0) return 0;
const q = query.toLowerCase();
const t = target.toLowerCase();
if (q === t) return 1.0;
// Stem match: query equals target with the first extension stripped.
// 'idea' against 'idea.md' → stem 'idea' → 1.0
// 'archive' against 'archive.tar.gz' → stem 'archive' → 1.0
// '.gitignore' (no real extension) → stem '.gitignore', query must match whole name
const firstDot = t.indexOf('.');
const stem = firstDot >= 1 ? t.slice(0, firstDot) : t;
if (q === stem) return 1.0;
if (t.startsWith(q)) return 0.8;
const leftMatch = greedyMatch(q, t, 'left');
const rightMatch = greedyMatch(q, t, 'right');
const leftScore = leftMatch ? scoreMatch(leftMatch, t) : 0;
const rightScore = rightMatch ? scoreMatch(rightMatch, t) : 0;
const best = Math.max(leftScore, rightScore);
return best > 0 ? Math.min(SUBSEQ_CAP, best) : 0;
}
/**
* Greedy subsequence match. Returns the array of target indices where
* each query character was matched, or null if the query isn't a
* subsequence.
*
* 'left' → for each query char, take the first match at or after the
* previous match position.
* 'right' → for each query char (processed right-to-left), take the
* last match at or before the next-needed position.
*/
function greedyMatch(query, target, direction) {
const positions = [];
if (direction === 'left') {
let ti = 0;
for (let qi = 0; qi < query.length; qi++) {
let found = -1;
for (let j = ti; j < target.length; j++) {
if (target[j] === query[qi]) {
found = j;
break;
}
}
if (found === -1) return null;
positions.push(found);
ti = found + 1;
}
return positions;
}
// right
let ti = target.length;
for (let qi = query.length - 1; qi >= 0; qi--) {
let found = -1;
for (let j = ti - 1; j >= 0; j--) {
if (target[j] === query[qi]) {
found = j;
break;
}
}
if (found === -1) return null;
positions.unshift(found);
ti = found;
}
return positions;
}
/**
* Score a match by counting boundary hits and consecutive pairs.
*/
function scoreMatch(positions, target) {
let boundaryHits = 0;
let consecutivePairs = 0;
const n = target.length;
for (let i = 0; i < positions.length; i++) {
const p = positions[i];
if (p === 0 || p === n - 1 || SEPARATORS.test(target[p - 1] || '')) {
boundaryHits++;
}
if (i > 0 && positions[i] === positions[i - 1] + 1) {
consecutivePairs++;
}
}
return 0.5 + boundaryHits * BOUNDARY_BONUS + consecutivePairs * CONSECUTIVE_BONUS;
}
/**
* Rank `items` against `query`, applying per-path boosts. Items with no
* match (and not boosted) are excluded.
*
* Boosts are multiplicative on the base score:
* - recent → ×2.0
* - openTabs → ×1.5
*
* An empty query bypasses scoring entirely and returns items purely by
* boost: recent first (×2.0), then open tabs (×1.5), then nothing.
*
* @param {string} query
* @param {Array<{path:string, name?:string}>} items
* @param {object} [boosts]
* @param {Set<string>} [boosts.recent]
* @param {Set<string>} [boosts.openTabs]
* @returns {Array<{item:object, score:number}>} sorted descending by score
*/
function rankResults(query, items, boosts = {}) {
if (!Array.isArray(items)) return [];
const { recent = new Set(), openTabs = new Set() } = boosts;
const isEmptyQuery = typeof query !== 'string' || query.length === 0;
const out = [];
for (const item of items) {
if (!item || typeof item.path !== 'string') continue;
let score;
if (isEmptyQuery) {
if (recent.has(item.path)) score = 2.0;
else if (openTabs.has(item.path)) score = 1.5;
else continue;
} else {
const base = fuzzyScore(query, item.name || '');
if (base === 0) continue;
score = base;
if (recent.has(item.path)) score *= 2.0;
if (openTabs.has(item.path)) score *= 1.5;
}
out.push({ item, score });
}
out.sort((a, b) => b.score - a.score);
return out;
}
module.exports = {
fuzzyScore,
rankResults,
// exported for tests / advanced callers
greedyMatch,
scoreMatch,
SUBSEQ_CAP,
};
@@ -0,0 +1,316 @@
/**
* Quick-switcher overlay UI.
*
* Modal centered over the viewport. Lists recent files, open tabs, and
* (when toggled) workspace files ranked by the shared fuzzy matcher.
*
* Pure logic — ranking, scoring — lives in fuzzy-matcher.js. This module
* owns DOM and keyboard only.
*
* @module quick-switcher-overlay
*/
const { rankResults: defaultRankResults } = require('./fuzzy-matcher');
/**
* @param {HTMLElement} container Mount point (typically document.body or a
* dedicated overlay host appended by the caller)
* @param {object} deps
* @param {() => string[]} [deps.getOpenTabPaths]
* @param {(dir:string, options?:object) => Promise<Array<{path:string, name:string}>>} [deps.listWorkspaceFiles]
* @param {(filePath:string) => void} [deps.onOpenFile]
* @param {() => string|null} [deps.getWorkspaceDir]
* @param {(query:string, items:Array, boosts:object) => Array} [deps.rankResults]
* @param {number} [deps.debounceMs=80]
* @returns {{
* show(opts?:{recent?:string[]}): void,
* hide(): void,
* destroy(): void,
* isOpen(): boolean,
* setRecent(recent:string[]): void,
* getSelectedPath(): string|null,
* }}
*/
function createQuickSwitcherOverlay(container, deps = {}) {
const {
getOpenTabPaths = () => [],
listWorkspaceFiles = async () => [],
onOpenFile = () => {},
getWorkspaceDir = () => null,
rankResults = defaultRankResults,
debounceMs = 80,
} = deps;
let modalEl = null;
let inputEl = null;
let listEl = null;
let emptyEl = null;
let workspaceToggleEl = null;
let open = false;
let currentQuery = '';
let recentFiles = [];
let workspaceFiles = [];
let workspaceMode = false;
let rankedItems = [];
let selectedIndex = 0;
let debounceTimer = null;
let keyListener = null;
let backdropListener = null;
function buildDom() {
const wrap = document.createElement('div');
wrap.className = 'quick-switcher-wrap';
wrap.innerHTML = `
<div class="quick-switcher-backdrop"></div>
<div class="quick-switcher-modal" role="dialog" aria-label="Quick switcher">
<input class="quick-switcher-input" type="text" autocomplete="off" spellcheck="false"
aria-label="Search files">
<div class="quick-switcher-toolbar">
<span class="quick-switcher-count" aria-live="polite"></span>
<button type="button" class="quick-switcher-workspace-toggle" aria-pressed="false">
Search workspace
</button>
</div>
<ul class="quick-switcher-list" role="listbox"></ul>
<div class="quick-switcher-empty" hidden>No matches</div>
</div>
`;
return wrap;
}
function attachListeners() {
if (!modalEl) return;
inputEl.addEventListener('input', onInput);
keyListener = (e) => {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
hide();
return;
}
// Only arrow/enter handled at document level (Esc, too); input handles
// text natively.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
updateSelection(e.key === 'ArrowDown' ? 1 : -1);
} else if (e.key === 'Enter') {
e.preventDefault();
openSelected();
}
};
// Capture so Esc works even if focus drifts (it usually shouldn't).
document.addEventListener('keydown', keyListener, true);
backdropListener = () => hide();
modalEl.querySelector('.quick-switcher-backdrop').addEventListener('click', backdropListener);
workspaceToggleEl.addEventListener('click', onToggleWorkspace);
}
function detachListeners() {
if (inputEl) inputEl.removeEventListener('input', onInput);
if (keyListener) document.removeEventListener('keydown', keyListener, true);
if (backdropListener && modalEl) {
const bd = modalEl.querySelector('.quick-switcher-backdrop');
if (bd) bd.removeEventListener('click', backdropListener);
}
if (workspaceToggleEl) workspaceToggleEl.removeEventListener('click', onToggleWorkspace);
keyListener = null;
backdropListener = null;
}
function onInput() {
currentQuery = inputEl.value;
scheduleRefresh();
}
function onToggleWorkspace() {
workspaceMode = !workspaceMode;
workspaceToggleEl.setAttribute('aria-pressed', String(workspaceMode));
workspaceToggleEl.classList.toggle('active', workspaceMode);
scheduleRefresh(true);
}
function scheduleRefresh(immediate = false) {
if (debounceTimer) clearTimeout(debounceTimer);
const run = () => {
debounceTimer = null;
refresh();
};
if (immediate || debounceMs <= 0) run();
else debounceTimer = setTimeout(run, debounceMs);
}
async function refresh() {
const dir = getWorkspaceDir();
if (workspaceMode && workspaceFiles.length === 0 && dir) {
try {
workspaceFiles = await listWorkspaceFiles(dir);
} catch {
workspaceFiles = [];
}
}
const openTabs = getOpenTabPaths();
const recentSet = new Set(recentFiles);
const openSet = new Set(openTabs);
const candidates = [];
if (workspaceMode) {
for (const f of workspaceFiles) candidates.push(f);
}
for (const path of recentFiles) {
if (!candidates.some((c) => c.path === path)) {
const name = path.split(/[\\/]/).pop() || path;
candidates.push({ path, name });
}
}
for (const path of openTabs) {
if (!candidates.some((c) => c.path === path)) {
const name = path.split(/[\\/]/).pop() || path;
candidates.push({ path, name });
}
}
rankedItems = rankResults(currentQuery, candidates, { recent: recentSet, openTabs: openSet });
// Keep selection in bounds
if (selectedIndex >= rankedItems.length) {
selectedIndex = Math.max(0, rankedItems.length - 1);
}
renderList();
}
function renderList() {
if (!modalEl) return;
listEl.innerHTML = '';
emptyEl.hidden = rankedItems.length > 0;
for (let i = 0; i < rankedItems.length; i++) {
const { item } = rankedItems[i];
const li = document.createElement('li');
li.className = 'quick-switcher-item' + (i === selectedIndex ? ' selected' : '');
li.setAttribute('role', 'option');
li.dataset.index = String(i);
li.dataset.path = item.path;
const baseName = item.name || item.path.split(/[\\/]/).pop() || item.path;
const parent = item.path.slice(0, item.path.length - baseName.length);
li.innerHTML = `
<span class="quick-switcher-name"></span>
<span class="quick-switcher-path"></span>
`;
li.querySelector('.quick-switcher-name').textContent = baseName;
li.querySelector('.quick-switcher-path').textContent = parent;
li.addEventListener('click', () => {
selectedIndex = i;
openSelected();
});
li.addEventListener('mouseenter', () => {
selectedIndex = i;
updateHighlight();
});
listEl.appendChild(li);
}
const count = modalEl.querySelector('.quick-switcher-count');
if (count) {
count.textContent = rankedItems.length
? `${rankedItems.length} ${rankedItems.length === 1 ? 'result' : 'results'}`
: '';
}
}
function updateHighlight() {
if (!listEl) return;
const items = listEl.querySelectorAll('.quick-switcher-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === selectedIndex);
});
}
function updateSelection(delta) {
if (rankedItems.length === 0) return;
selectedIndex = Math.max(0, Math.min(rankedItems.length - 1, selectedIndex + delta));
updateHighlight();
}
function openSelected() {
const sel = rankedItems[selectedIndex];
if (!sel) return;
onOpenFile(sel.item.path);
hide();
}
function show({ recent = [] } = {}) {
recentFiles = recent;
currentQuery = '';
selectedIndex = 0;
workspaceMode = false;
workspaceFiles = [];
if (!modalEl) {
modalEl = buildDom();
container.appendChild(modalEl);
inputEl = modalEl.querySelector('.quick-switcher-input');
listEl = modalEl.querySelector('.quick-switcher-list');
emptyEl = modalEl.querySelector('.quick-switcher-empty');
workspaceToggleEl = modalEl.querySelector('.quick-switcher-workspace-toggle');
attachListeners();
}
inputEl.value = '';
workspaceToggleEl.setAttribute('aria-pressed', 'false');
workspaceToggleEl.classList.remove('active');
modalEl.classList.add('open');
open = true;
inputEl.focus();
scheduleRefresh(true);
}
function hide() {
open = false;
if (modalEl) modalEl.classList.remove('open');
}
function destroy() {
detachListeners();
if (modalEl && modalEl.parentNode) modalEl.parentNode.removeChild(modalEl);
modalEl = null;
inputEl = null;
listEl = null;
emptyEl = null;
workspaceToggleEl = null;
}
function isOpenFn() {
return open;
}
function setRecentFn(recent) {
recentFiles = Array.isArray(recent) ? recent : [];
if (open) scheduleRefresh(true);
}
function getSelectedPathFn() {
const sel = rankedItems[selectedIndex];
return sel ? sel.item.path : null;
}
return {
show,
hide,
destroy,
isOpen: isOpenFn,
setRecent: setRecentFn,
getSelectedPath: getSelectedPathFn,
};
}
module.exports = { createQuickSwitcherOverlay };
@@ -0,0 +1,39 @@
/**
* Workspace dir resolver for the Quick Switcher overlay.
*
* Derives a workspace directory from a single file path by returning its
* parent directory. Auto-detects path separator (Windows backslash vs
* POSIX forward slash).
*
* Returns null when no usable file path is supplied (untitled tab, no
* active tab) — the renderer then surfaces only recent + open tabs in
* the Quick Switcher, and the workspace toggle is a no-op.
*
* @module workspace-dir-resolver
*/
/**
* @param {string|null|undefined} filePath
* @returns {string|null} parent directory, or null if not derivable
*/
function deriveWorkspaceDir(filePath) {
if (typeof filePath !== 'string' || filePath.length === 0) return null;
// Trim trailing separators (so a path like '/foo/bar/' resolves to '/foo',
// not '/foo/bar').
let end = filePath.length;
while (end > 0 && (filePath[end - 1] === '/' || filePath[end - 1] === '\\')) {
end--;
}
if (end === 0) return null;
const trimmed = filePath.slice(0, end);
// Find the last separator of either kind — mixed paths are ambiguous
// but taking the last one of either kind matches what users mean when
// they paste a path with mixed separators.
const lastSlash = trimmed.lastIndexOf('/');
const lastBack = trimmed.lastIndexOf('\\');
const lastSep = Math.max(lastSlash, lastBack);
if (lastSep <= 0) return null;
return trimmed.slice(0, lastSep);
}
module.exports = { deriveWorkspaceDir };
+111
View File
@@ -0,0 +1,111 @@
/**
* Workspace file lister for the quick-switcher.
*
* Walks a directory tree and returns every entry whose extension matches a
* configurable allowlist. Skips:
* - hidden directories (leading '.')
* - `node_modules` and other known non-content dirs (dist, build, .next, …)
*
* The walk is injectable so tests use real fs via `mkdtempSync`. A cap
* (`maxResults`) prevents pathological workspaces (50k+ files) from
* blowing the IPC payload or the renderer's filter loop.
*
* @module workspace-file-lister
*/
const path = require('path');
const DEFAULT_EXTENSIONS = ['.md', '.markdown', '.mdx', '.txt'];
const DEFAULT_MAX_RESULTS = 2000;
// Known large/output directories that shouldn't appear in a file picker
const SKIP_DIRS = new Set([
'node_modules',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.cache',
'.parcel-cache',
'.turbo',
'coverage',
'__snapshots__',
]);
function isHiddenDir(name) {
return name.startsWith('.');
}
function isSkippedDir(name) {
return isHiddenDir(name) || SKIP_DIRS.has(name);
}
/**
* @param {string} dir - Workspace root (absolute path)
* @param {object} [options]
* @param {string[]} [options.extensions] - Extensions to match (with leading dot)
* @param {number} [options.maxResults] - Hard cap on results
* @param {object} [options.io] - Injectable IO `{ fs, pathUtil }`. Defaults
* to the real `node:fs` and `node:path`.
* @returns {Array<{path:string, name:string}>}
*/
function listWorkspaceFiles(dir, options = {}) {
if (typeof dir !== 'string' || dir.length === 0) {
throw new Error('listWorkspaceFiles: dir must be a non-empty string');
}
const {
extensions = DEFAULT_EXTENSIONS,
maxResults = DEFAULT_MAX_RESULTS,
io = { fs: require('fs'), pathUtil: path },
} = options;
if (!Array.isArray(extensions) || extensions.length === 0) {
throw new Error('listWorkspaceFiles: extensions must be a non-empty array');
}
if (typeof maxResults !== 'number' || maxResults <= 0) {
throw new Error('listWorkspaceFiles: maxResults must be a positive number');
}
const normalizedExts = new Set(extensions.map((e) => String(e).toLowerCase()));
const results = [];
walk(dir, normalizedExts, results, maxResults, io.fs, io.pathUtil);
return results;
}
function walk(currentDir, exts, results, maxResults, fs, pathUtil) {
if (results.length >= maxResults) return;
let entries;
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch (err) {
// Permission denied / ENOENT at a subdir shouldn't kill the walk.
// Real failures propagate when the root itself is unreadable — main
// process callers treat that as an empty result.
if (err && (err.code === 'EACCES' || err.code === 'ENOENT')) return;
throw err;
}
for (const entry of entries) {
if (results.length >= maxResults) return;
if (entry.isDirectory()) {
if (isSkippedDir(entry.name)) continue;
walk(pathUtil.join(currentDir, entry.name), exts, results, maxResults, fs, pathUtil);
} else if (entry.isFile()) {
const ext = pathUtil.extname(entry.name).toLowerCase();
if (exts.has(ext)) {
results.push({
path: pathUtil.join(currentDir, entry.name),
name: entry.name,
});
}
}
}
}
module.exports = {
listWorkspaceFiles,
DEFAULT_EXTENSIONS,
DEFAULT_MAX_RESULTS,
SKIP_DIRS,
};
+467 -807
View File
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
/**
* ASCII Art Generator — renderer controller for the standalone window.
*
* Drives the DOM in src/ascii-generator.html. Fetches the font list from
* the main process once on mount, renders previews on input, and exposes
* Insert / Copy / Save actions. Last-used font is restored from
* electron-store (via main) and saved on every change.
*
* Wires three mode tabs (Text Banner / Box-Frame / Templates) plus the
* 18 template buttons and the Box form fields (#box-text, #box-style,
* #box-padding). Templates route through `api.generate({ font: 'template:<id>' })`
* so the orchestrator owns template content.
*
* No bundler, no framework — pure DOM + window.electronAPI bridge.
*/
(function () {
'use strict';
const api =
window.electronAPI && window.electronAPI.generators && window.electronAPI.generators.ascii;
const els = {
textInput: document.getElementById('text-input'),
fontPicker: document.getElementById('font-picker'),
fontSearch: document.getElementById('font-search'),
preview: document.getElementById('preview'),
btnInsert: document.getElementById('btn-insert'),
btnCopy: document.getElementById('btn-copy'),
btnSave: document.getElementById('btn-save'),
btnGenerate: document.getElementById('btn-generate'),
warning: document.getElementById('ascii-warning'),
modeTabs: document.querySelectorAll('.mode-tab[data-mode]'),
modeSections: document.querySelectorAll('.mode-section'),
templateButtons: document.querySelectorAll('.template-btn[data-template]'),
boxText: document.getElementById('box-text'),
boxStyle: document.getElementById('box-style'),
boxPadding: document.getElementById('box-padding'),
};
// Section ID for each mode. Keep in sync with ascii-generator.html IDs.
const MODE_SECTION_ID = {
text: 'text-mode',
box: 'box-mode',
templates: 'templates-mode',
};
let _fonts = [];
let _currentFont = 'standard';
let _previewText = '';
let _searchDebounce = null;
let _currentMode = 'text';
function showWarning(msg) {
if (!els.warning) return;
els.warning.textContent = msg || '';
els.warning.style.display = msg ? 'block' : 'none';
}
function debounce(fn, ms) {
let t = null;
return function () {
const args = arguments;
clearTimeout(t);
t = setTimeout(() => fn.apply(null, args), ms);
};
}
/**
* Switch the active mode tab and the visible mode section.
* Toggles the `.active` class on tabs and on the matching `.mode-section`.
* @param {'text'|'box'|'templates'} mode
*/
function setMode(mode) {
if (!MODE_SECTION_ID[mode]) return;
_currentMode = mode;
// Toggle tab buttons.
els.modeTabs.forEach((tab) => {
if (tab.dataset.mode === mode) tab.classList.add('active');
else tab.classList.remove('active');
});
// Toggle mode sections.
els.modeSections.forEach((sec) => {
if (sec.id === MODE_SECTION_ID[mode]) sec.classList.add('active');
else sec.classList.remove('active');
});
// Each mode has its own preview pipeline; trigger a refresh.
refreshPreview();
}
/**
* Build a simple box border around the user's text using the chosen style.
* Pure function — exported on window for tests via ASCIIBoxRenderer.
* @param {string} text
* @param {'single'|'double'|'rounded'|'bold'|'ascii'} style
* @param {number} padding 0..10 spaces on each side
* @returns {string}
*/
function renderBox(text, style, padding) {
const lines = String(text ?? '').split('\n');
const pad = Math.max(0, Math.min(10, Number(padding) || 0));
const padded = lines.map((l) => ' '.repeat(pad) + l + ' '.repeat(pad));
const width = padded.reduce((w, l) => Math.max(w, visibleLength(l)), 0);
let tl, tr, bl, br, h, v;
switch (style) {
case 'double':
tl = '╔';
tr = '╗';
bl = '╚';
br = '╝';
h = '═';
v = '║';
break;
case 'rounded':
tl = '╭';
tr = '╮';
bl = '╰';
br = '╯';
h = '─';
v = '│';
break;
case 'bold':
tl = '┏';
tr = '┓';
bl = '┗';
br = '┛';
h = '━';
v = '┃';
break;
case 'ascii':
tl = '+';
tr = '+';
bl = '+';
br = '+';
h = '-';
v = '|';
break;
case 'single':
default:
tl = '┌';
tr = '┐';
bl = '└';
br = '┘';
h = '─';
v = '│';
break;
}
const top = tl + h.repeat(width + 2) + tr;
const bot = bl + h.repeat(width + 2) + br;
const body = padded.map((l) => v + ' ' + l.padEnd(width, ' ') + ' ' + v).join('\n');
return [top, body, bot].join('\n');
}
/**
* Count printable character width, ignoring box-drawing characters and
* spaces (which all occupy a single monospace column).
*/
function visibleLength(s) {
// Box-drawing chars and most printable ASCII occupy one column in our
// monospace font. This is a deliberate simplification — emoji or wide
// CJK would need a more sophisticated width table.
return Array.from(s).length;
}
async function refreshPreview() {
if (!api) return;
if (_currentMode === 'templates') {
// Template mode is button-driven; no live preview here.
return;
}
if (_currentMode === 'box') {
const text = els.boxText ? els.boxText.value : '';
const style = els.boxStyle ? els.boxStyle.value : 'single';
const padding = els.boxPadding ? els.boxPadding.value : 2;
_previewText = text;
const out = renderBox(text, style, padding);
els.preview.textContent = out;
showWarning('');
return;
}
// text mode (default)
const text = els.textInput ? els.textInput.value : 'HELLO';
_previewText = text;
try {
const out = await api.generate({ text, font: _currentFont });
els.preview.textContent = out;
showWarning('');
} catch (err) {
els.preview.textContent = '';
showWarning(`Failed to render: ${err.message || err}`);
}
}
function renderFontList(filter) {
if (!els.fontPicker) return;
const f = (filter || '').toLowerCase();
const filtered = _fonts.filter((font) => {
if (!f) return true;
return font.label.toLowerCase().includes(f) || font.id.toLowerCase().includes(f);
});
els.fontPicker.innerHTML = '';
for (const font of filtered.slice(0, 500)) {
const opt = document.createElement('option');
opt.value = font.id;
opt.textContent = `${font.label} (${font.kind})`;
if (font.id === _currentFont) opt.selected = true;
els.fontPicker.appendChild(opt);
}
}
async function loadFonts() {
if (!api) return;
try {
_fonts = await api.listFonts();
} catch {
_fonts = [];
showWarning('Could not load font list. Hand-coded fonts only.');
}
try {
const last = await api.lastFont(null);
if (last && _fonts.some((f) => f.id === last)) _currentFont = last;
} catch {
/* ignore */
}
renderFontList('');
}
/**
* Render a template by id: fetch its content via the orchestrator's
* template:<id> font namespace and update the preview.
*/
async function renderTemplate(templateId) {
if (!api) return;
try {
const out = await api.generate({ text: '', font: `template:${templateId}` });
els.preview.textContent = out;
showWarning('');
// Mark the clicked button as active.
els.templateButtons.forEach((btn) => {
if (btn.dataset.template === templateId) btn.classList.add('active');
else btn.classList.remove('active');
});
} catch (err) {
showWarning(`Template render failed: ${err.message || err}`);
}
}
function wireEvents() {
if (els.textInput) {
els.textInput.addEventListener('input', debounce(refreshPreview, 200));
}
if (els.fontPicker) {
els.fontPicker.addEventListener('change', () => {
_currentFont = els.fontPicker.value;
if (api) api.lastFont(_currentFont).catch(() => {});
refreshPreview();
});
}
if (els.fontSearch) {
els.fontSearch.addEventListener('input', () => {
clearTimeout(_searchDebounce);
_searchDebounce = setTimeout(() => renderFontList(els.fontSearch.value), 100);
});
}
if (els.btnGenerate) {
els.btnGenerate.addEventListener('click', refreshPreview);
}
if (els.btnInsert) {
els.btnInsert.addEventListener('click', () => {
const wrapped =
'```\n' + (_previewText ? document.getElementById('preview').textContent : '') + '\n```';
if (window.electronAPI && window.electronAPI.send) {
window.electronAPI.send('insert-generated-content', wrapped);
window.close();
}
});
}
if (els.btnCopy) {
els.btnCopy.addEventListener('click', async () => {
const text = els.preview.textContent;
if (!text) return;
try {
await api.copy(text);
showWarning('Copied to clipboard.');
setTimeout(() => showWarning(''), 1500);
} catch (err) {
showWarning('Copy failed: ' + (err.message || err));
}
});
}
if (els.btnSave) {
els.btnSave.addEventListener('click', async () => {
const text = els.preview.textContent;
if (!text) return;
try {
const result = await api.save({ text, defaultName: 'ascii-art.txt' });
if (!result.canceled) {
showWarning(`Saved to ${result.path}`);
setTimeout(() => showWarning(''), 2000);
}
} catch (err) {
showWarning('Save failed: ' + (err.message || err));
}
});
}
// ---- mode-tab switching (Text Banner / Box-Frame / Templates) ----
els.modeTabs.forEach((tab) => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
if (mode) setMode(mode);
});
});
// ---- Box form: any change re-renders the box preview ----
if (els.boxText) {
els.boxText.addEventListener('input', () => {
if (_currentMode === 'box') refreshPreview();
});
}
if (els.boxStyle) {
els.boxStyle.addEventListener('change', () => {
if (_currentMode === 'box') refreshPreview();
});
}
if (els.boxPadding) {
els.boxPadding.addEventListener('input', () => {
if (_currentMode === 'box') refreshPreview();
});
}
// ---- Template buttons: render the chosen template into the preview ----
els.templateButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.template;
if (id) renderTemplate(id);
});
});
}
async function bootstrap() {
wireEvents();
await loadFonts();
await refreshPreview();
}
// Expose pure helpers for tests; do not pollute window in production beyond
// what the bootstrap entry-point needs.
window.ASCIIController = { bootstrap };
window.ASCIIBoxRenderer = { renderBox };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();
+174
View File
@@ -0,0 +1,174 @@
/**
* Renderer-side autosave controller.
*
* Buffers every keystroke and flushes the current document body to the main
* process at most once per AUTOSAVE_DEBOUNCE_MS. The main process persists it
* under <userData>/autosave/ via the AutosaveBuffer module; a successful
* explicit save clears the entry because the buffer is now on disk at its
* real path.
*
* Three things the controller is responsible for:
* 1. Per-tab debounced flush — registerTab(tabId, getDocPath, getContent)
* wires up the writer; unregisterTab(tabId) tears it down on close.
* 2. Periodic flush while the buffer is dirty — a hidden interval makes
* sure the user can quit any time and not lose more than a few seconds
* of work, even if they don't type again.
* 3. Startup recovery sweep — checkPendingRecoveries() lists every
* pending recovery on startup so the UI can offer to restore.
*
* Recovery banners and "save cleared autosave" UX live in the renderer, not
* here — this module only manages the I/O.
*/
const { ipcRenderer } = require('electron');
const AUTOSAVE_DEBOUNCE_MS = 2000;
const AUTOSAVE_MAX_INTERVAL_MS = 10000; // flush dirty buffers even when idle
class AutosaveController {
constructor() {
/** Map<tabId, {docPath, getContent, timer, dirty, lastFlushAt}> */
this._tabs = new Map();
this._interval = null;
}
/**
* Begin autosaving a tab. The docPath may be a synthetic 'untitled-tab-<id>'
* for never-saved buffers; it may also be a real absolute path.
*
* @param {string|number} tabId
* @param {() => string|null|undefined} getDocPath returns the current path
* (or null/untitled key) for the tab
* @param {() => string} getContent returns the current buffer
*/
registerTab(tabId, getDocPath, getContent) {
if (this._tabs.has(tabId)) return; // already registered
this._tabs.set(tabId, {
docPath: null,
getDocPath,
getContent,
timer: null,
dirty: false,
lastFlushAt: 0,
});
this._ensureInterval();
}
/**
* Stop autosaving a tab. Called on close; the persisted recovery blob is
* left in place — if the user reopens the file later, the recovery banner
* will offer to restore it.
*/
unregisterTab(tabId) {
const entry = this._tabs.get(tabId);
if (!entry) return;
if (entry.timer) clearTimeout(entry.timer);
this._tabs.delete(tabId);
if (this._tabs.size === 0 && this._interval) {
clearInterval(this._interval);
this._interval = null;
}
}
/**
* Notify the controller that the tab's content changed. Schedules a debounced
* flush.
*/
notifyChange(tabId) {
const entry = this._tabs.get(tabId);
if (!entry) return;
entry.dirty = true;
if (entry.timer) clearTimeout(entry.timer);
entry.timer = setTimeout(() => this._flush(tabId), AUTOSAVE_DEBOUNCE_MS);
}
/**
* Flush a specific tab immediately. Called after explicit saves and at
* shutdown.
*/
async flushNow(tabId) {
return this._flush(tabId);
}
/**
* Clear the recovery entry for a document — called after a successful save
* (the buffer is now on disk at its real path).
*/
async clearForDocPath(docPath) {
if (!docPath) return false;
try {
return await ipcRenderer.invoke('autosave:clear', { docPath });
} catch {
return false;
}
}
/**
* List every pending recovery across all documents. Returns
* [{docPath, savedAt, byteSize, appVersion}] newest-first.
*/
async checkPendingRecoveries() {
try {
return await ipcRenderer.invoke('autosave:list');
} catch {
return [];
}
}
/**
* Read a specific recovery entry (content + meta). Returns null when the
* entry no longer exists or the docPath is missing.
*/
async readRecovery(docPath) {
if (!docPath) return null;
try {
return await ipcRenderer.invoke('autosave:read', { docPath });
} catch {
return null;
}
}
/**
* Drop a specific recovery entry — called when the user dismisses the
* recovery banner without restoring.
*/
async dismissRecovery(docPath) {
return this.clearForDocPath(docPath);
}
// ---- private ----
_ensureInterval() {
if (this._interval) return;
this._interval = setInterval(() => {
const now = Date.now();
for (const [tabId, entry] of this._tabs) {
if (!entry.dirty) continue;
if (now - entry.lastFlushAt < AUTOSAVE_MAX_INTERVAL_MS) continue;
// Best-effort flush — if main is busy, leave the timer in place.
this._flush(tabId).catch(() => {});
}
}, AUTOSAVE_MAX_INTERVAL_MS / 2);
}
async _flush(tabId) {
const entry = this._tabs.get(tabId);
if (!entry) return;
entry.timer = null;
if (!entry.dirty) return;
const docPath = entry.getDocPath();
if (!docPath) return; // tab has no path yet — nothing to recover
const content = entry.getContent();
entry.dirty = false;
entry.lastFlushAt = Date.now();
try {
await ipcRenderer.invoke('autosave:write', { docPath, content });
} catch (err) {
// Re-arm so the next change retries; don't swallow silently.
entry.dirty = true;
console.warn('[autosave] write failed:', err && err.message);
}
}
}
module.exports = { AutosaveController, AUTOSAVE_DEBOUNCE_MS };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
/**
* Flowchart Generator — renderer controller for the standalone window.
*
* Drives the DOM in src/flowchart-generator.html. Boots the graph store,
* hydrates from <userData>/flowchart-session.json (reusing the existing
* thin text-file IPC handlers), persists on every mutation with a
* 500 ms debounce, and wires "Insert at Cursor" to the existing
* `insert-content` IPC so the Mermaid source lands in the main editor
* wrapped in a fenced code block.
*
* Pure browser script — no require(), no node. Pure modules
* (flowchart-store / flowchart-canvas / flowchart-mermaid / flowchart-shapes)
* are loaded via <script> tags in src/flowchart-generator.html and exposed
* as window globals (window.FlowchartStore, window.FlowchartCanvas,
* window.FlowchartMermaid, window.FlowchartShapes).
*
* @module flowchart-controller
*/
(function () {
'use strict';
const api =
window.electronAPI && window.electronAPI.flowchart ? window.electronAPI.flowchart : null;
const els = {
canvasHost: document.getElementById('canvas-host'),
previewSource: document.getElementById('preview-source'),
previewRender: document.getElementById('preview-render'),
btnInsert: document.getElementById('fc-btn-insert'),
btnReset: document.getElementById('fc-btn-reset'),
status: document.getElementById('fc-status'),
};
// Guard rails — these should never be null in a correctly-launched window.
// Fail loudly with a visible status message rather than silently no-op'ing
// if the HTML or the preload bridge are misconfigured.
function fatal(msg) {
if (els.status) els.status.textContent = msg;
console.error('[flowchart-controller]', msg);
}
if (!els.canvasHost || !els.previewSource || !els.previewRender) {
fatal('Required DOM elements missing — check src/flowchart-generator.html');
return;
}
if (
!window.FlowchartStore ||
!window.FlowchartCanvas ||
!window.FlowchartMermaid ||
!window.FlowchartShapes
) {
fatal('Flowchart pure modules not loaded — verify script tags in src/flowchart-generator.html');
return;
}
if (!api) {
fatal('window.electronAPI.flowchart missing — check src/preload.js');
return;
}
const { create: createStore } = window.FlowchartStore;
const { createCanvas } = window.FlowchartCanvas;
const { toMermaid } = window.FlowchartMermaid;
const { promptInline, confirmInline: _confirmInline } = window.FlowchartModals || {};
void _confirmInline; // exposed by the bundle for future use (e.g. reset confirmation)
// FlowchartShapes is intentionally unused here but its presence is
// required for createCanvas() to function; the guard above guarantees it.
const PREVIEW_DEBOUNCE_MS = 250;
const PERSIST_DEBOUNCE_MS = 500;
const PERSISTENCE_FILENAME = 'flowchart-session.json';
const NODE_KIND_OPTIONS_TEXT = 'process, decision, terminator, subroutine, document';
const EDGE_KIND_OPTIONS_TEXT = 'solid, dotted, thick';
let _userDataPath = null;
let _persistenceFile = null;
let _previewTimer = null;
let _persistTimer = null;
let _store = null;
let _canvas = null;
function setStatus(msg) {
if (els.status) els.status.textContent = msg || '';
if (msg) setTimeout(() => setStatus(''), 2500);
}
function runPreview() {
if (!_store || !els.previewSource || !els.previewRender) return;
const graph = _store.getGraph();
const source = toMermaid(graph);
els.previewSource.textContent = source;
// v4.9.6 — text preview only. The SVG canvas on the left is the
// visual preview (hand-rolled, no Mermaid runtime needed in this
// window's renderer). Mirrors the v4.9.5 fix that forced a light
// surface to guarantee visibility.
els.previewRender.textContent = '';
}
function debouncedPreview() {
if (_previewTimer) clearTimeout(_previewTimer);
_previewTimer = setTimeout(() => {
_previewTimer = null;
runPreview();
}, PREVIEW_DEBOUNCE_MS);
}
function debouncedPersist() {
if (_persistTimer) clearTimeout(_persistTimer);
_persistTimer = setTimeout(() => {
_persistTimer = null;
if (!_store || !_persistenceFile) return;
api
.writeFile(_persistenceFile, _store.serialize())
.then(() => setStatus('Saved'))
.catch((err) => setStatus(`Save failed: ${err && err.message ? err.message : err}`));
}, PERSIST_DEBOUNCE_MS);
}
async function bootstrap() {
// Resolve the userData path ONCE on mount. The persistence path is
// interpolated into a string on every read/write; calling the async
// api.getUserDataPath() directly would coerce the Promise to
// "[object Promise]" and break the userData sandbox check in
// main.js:write-text-file.
try {
_userDataPath = await api.getUserDataPath();
} catch (err) {
fatal(`Could not resolve userData path: ${err && err.message ? err.message : err}`);
return;
}
_persistenceFile = `${_userDataPath}/${PERSISTENCE_FILENAME}`;
_store = createStore({
persistencePath: _persistenceFile,
readFile: api.readFile,
writeFile: api.writeFile,
now: () => Date.now(),
});
_canvas = createCanvas(els.canvasHost, _store, {
onEdgeClick: async (edgeId) => {
const edge = _store.getGraph().edges.find((e) => e.id === edgeId);
if (!edge) return;
// v4.13.0 — replace window.prompt() with the inline modal helper.
// Two sequential prompts: kind first (with the current value as
// default), then label.
const nextKind = await promptInline({
title: 'Edge Style',
message: `Style (${EDGE_KIND_OPTIONS_TEXT}):`,
defaultValue: edge.kind,
});
if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) {
_store.setEdgeKind(edgeId, nextKind);
}
const nextLabel = await promptInline({
title: 'Edge Label',
message: 'Empty to clear:',
defaultValue: edge.label || '',
});
if (nextLabel !== null) {
_store.setEdgeLabel(edgeId, nextLabel);
}
},
onNodeClick: () => {
// Canvas already paints the .selected highlight; nothing else
// needed here for selection state.
},
onShapeMenu: async (nodeId) => {
const node = _store.getGraph().nodes.find((n) => n.id === nodeId);
if (!node) return;
const next = await promptInline({
title: 'Change Shape',
message: `New shape (${NODE_KIND_OPTIONS_TEXT}):`,
defaultValue: node.kind,
});
if (next) _store.setNodeKind(nodeId, next);
},
onEmptyClick: async (x, y) => {
// v4.13.0 — canvas click no longer auto-creates a process node
// with literal label "Node". User picks the shape kind first.
const kind = await promptInline({
title: 'Add Node',
message: `Shape (${NODE_KIND_OPTIONS_TEXT}):`,
defaultValue: 'process',
});
if (!kind) return;
const label = await promptInline({
title: 'Node Label',
message: 'Label (empty for "Node"):',
defaultValue: '',
});
const finalLabel = label && label.length > 0 ? label : 'Node';
_store.addNode({ kind, x, y, label: finalLabel });
},
});
_store.subscribe(() => {
debouncedPreview();
debouncedPersist();
});
// Hydrate from disk (defensively — corrupt JSON is caught by the store).
try {
const json = await api.readFile(_persistenceFile);
if (json) _store.deserialize(json);
} catch (err) {
console.warn('[flowchart-controller] failed to read session:', err);
}
if (els.btnInsert) {
els.btnInsert.addEventListener('click', () => {
if (!_store) return;
const source = toMermaid(_store.getGraph());
const fenced = '```mermaid\n' + source + '\n```';
if (api.insertAtCursor) api.insertAtCursor(fenced);
setStatus('Inserted');
});
}
if (els.btnReset) {
els.btnReset.addEventListener('click', () => {
if (!_store) return;
if (!window.confirm('Clear all nodes and edges?')) return;
_store.deserialize({ nodes: [], edges: [] });
setStatus('Reset');
});
}
// Keyboard shortcuts — Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z / Delete / Backspace.
document.addEventListener('keydown', (ev) => {
if (!_store) return;
const meta = ev.ctrlKey || ev.metaKey;
if (meta && !ev.shiftKey && ev.key.toLowerCase() === 'z') {
ev.preventDefault();
_store.undo();
return;
}
if (meta && ev.shiftKey && ev.key.toLowerCase() === 'z') {
ev.preventDefault();
_store.redo();
return;
}
if (ev.key === 'Delete' || ev.key === 'Backspace') {
// Delete applies to the canvas's currently-selected node/edge.
// The canvas doesn't expose selection state directly; we read
// it from the DOM .selected class.
const selectedNode = els.canvasHost.querySelector('.flowchart-node.selected');
if (selectedNode) {
const id = selectedNode.getAttribute('data-node-id');
if (id) {
ev.preventDefault();
_store.removeNode(id);
}
return;
}
const selectedEdge = els.canvasHost.querySelector('.flowchart-edge.selected');
if (selectedEdge) {
const id = selectedEdge.getAttribute('data-edge-id');
if (id) {
ev.preventDefault();
_store.disconnect(id);
}
}
}
});
runPreview();
setStatus('Ready');
}
// Expose a minimal handle for tests (mirrors ascii-controller.js pattern).
window.FlowchartController = {
bootstrap,
get store() {
return _store;
},
get canvas() {
return _canvas;
},
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();
+125
View File
@@ -0,0 +1,125 @@
/**
* Footnote hover preview.
*
* marked-footnote renders references as <a data-footnote-ref href="#footnote-N">
* and bodies as <li id="footnote-N"> inside <section class="footnotes">.
* When the user hovers a reference, this module shows a small popover with
* the corresponding body text; on mouseleave it disappears.
*
* Pure DOM — no IPC, no markdown parsing. Mount once per preview pane and
* forget about it. Re-mounting on preview re-render is safe: the old
* listeners are replaced cleanly.
*
* @param {HTMLElement} previewRoot The preview pane container.
* @param {object} [opts]
* @param {number} [opts.delayMs=180] Show delay so the popover doesn't flicker
* during quick mouse passes.
*/
function mountFootnotePreview(previewRoot, opts = {}) {
if (!previewRoot) return () => {};
const delayMs = typeof opts.delayMs === 'number' ? opts.delayMs : 180;
let pendingTimer = null;
let activeRef = null;
// Create the popover once at mount time so the first hover is instant and
// tests can assert presence without racing the timer.
const popover = document.createElement('div');
popover.className = 'footnote-preview';
popover.setAttribute('role', 'tooltip');
popover.style.cssText =
'position:fixed;z-index:100000;max-width:min(420px, 80vw);background:#1f1f23;color:#eee;' +
'padding:8px 12px;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.25);font-size:12px;' +
'line-height:1.4;pointer-events:none;display:none;white-space:pre-wrap;word-wrap:break-word;';
document.body.appendChild(popover);
function ensurePopover() {
return popover;
}
function lookupFootnoteBody(href) {
// href looks like "#footnote-1" — extract the id and find the matching <li>
if (!href || !href.startsWith('#footnote-')) return null;
const id = href.slice(1); // "#footnote-1" → "footnote-1"
// CSS.escape() is in modern browsers but not in every test env; fallback
// strips characters that would break the selector without escaping the
// whole id (footnote ids are integers, so this is safe).
const safeId =
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/[^a-zA-Z0-9_-]/g, '');
const target = previewRoot.querySelector(`#${safeId}`);
return target || null;
}
function extractBodyText(footnoteEl) {
// Drop the backref link — the user already knows how to navigate back
const clone = footnoteEl.cloneNode(true);
clone.querySelectorAll('[data-footnote-backref]').forEach((el) => el.remove());
// Take only the first paragraph's text so we don't show the whole section
const p = clone.querySelector('p') || clone;
return (p.textContent || '').trim();
}
function show(refEl) {
const href = refEl.getAttribute('href');
const li = lookupFootnoteBody(href);
if (!li) return;
const text = extractBodyText(li);
if (!text) return;
const el = ensurePopover();
el.textContent = text;
el.style.display = 'block';
// Position the popover above the reference, falling back to below if there's no room
const rect = refEl.getBoundingClientRect();
const popRect = el.getBoundingClientRect();
let top = rect.top - popRect.height - 8;
if (top < 4) top = rect.bottom + 8;
let left = rect.left;
if (left + popRect.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - popRect.width - 8);
}
el.style.top = `${top}px`;
el.style.left = `${left}px`;
activeRef = refEl;
}
function hide() {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
if (popover) popover.style.display = 'none';
activeRef = null;
}
function onMouseOver(ev) {
const t = ev.target;
if (!(t instanceof Element)) return;
const ref = t.closest('a[data-footnote-ref]');
if (!ref) return;
if (activeRef === ref) return;
activeRef = ref;
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => show(ref), delayMs);
}
function onMouseOut(ev) {
const t = ev.target;
if (!(t instanceof Element)) return;
if (!t.closest('a[data-footnote-ref]')) return;
// If the mouse moved into the popover itself, keep it visible; otherwise hide.
const related = ev.relatedTarget;
if (related && popover && popover.contains(related)) return;
hide();
}
previewRoot.addEventListener('mouseover', onMouseOver);
previewRoot.addEventListener('mouseout', onMouseOut);
return function unmount() {
previewRoot.removeEventListener('mouseover', onMouseOver);
previewRoot.removeEventListener('mouseout', onMouseOut);
hide();
if (popover && popover.parentNode) popover.parentNode.removeChild(popover);
};
}
module.exports = { mountFootnotePreview };
+275
View File
@@ -0,0 +1,275 @@
/**
* @jest-environment jsdom
*
* Inline AI assist popover — DOM + interaction tests.
*
* Pure controller. Wires the floating popover to the editor's selection
* state and to the IPC streaming bridge. Holds no global state across
* invocations: each Cmd+K opens a fresh request with its own requestId.
*
* Public API:
* const ctl = createInlineAiController({ ... });
* ctl.attach() // registers Cmd+K keymap
* ctl.detach() // tears down
*
* Or call ctl.showForSelection() directly from a custom shortcut.
*
* @module inline-ai-controller
*/
const { createInlineAiPopover } = require('../ai-assist/inline-ai-popover');
const {
buildAssistPrompt,
applyAssistResult,
MAX_SELECTION_CHARS,
} = require('../ai-assist/inline-assist');
/**
* @param {object} deps
* @param {() => any} deps.getEditorView - returns CodeMirror EditorView
* @param {object} deps.electronAPI - window.electronAPI shape
* @param {(key:string, e:Event) => boolean} [deps.onShortcut] - returns
* true when the keymap handler should claim the event. Used to keep
* Esc-cancel from firing when the popover isn't open.
*/
function createInlineAiController(deps) {
const { getEditorView, electronAPI, onShortcut, confirmFirstUse, getProviderLabel } = deps;
if (typeof getEditorView !== 'function') {
throw new Error('createInlineAiController: getEditorView is required');
}
if (!electronAPI || !electronAPI.aiAssist) {
throw new Error('createInlineAiController: electronAPI.aiAssist is required');
}
let popover = null;
let activeRequestId = null;
let activeSelection = null; // { from, to, original }
let activeAction = null; // 'rewrite' | 'shorten' | 'expand'
let unsubscribers = []; // [{ off }]
let firstUseConfirmed = false; // session-scoped — never re-prompts after OK
function ensurePopover() {
if (popover) return popover;
popover = createInlineAiPopover(document.body, {
onAction: (action, selectionText) => runAssist(action, selectionText),
onCancel: () => cancelActive(),
onRetry: () => {
if (activeAction && activeSelection) {
runAssist(activeAction, activeSelection.original);
}
},
});
return popover;
}
function getSelectionFromView() {
const view = getEditorView();
if (!view) return null;
const sel = view.state.selection.main;
if (sel.empty) return null;
const text = view.state.sliceDoc(sel.from, sel.to);
return { from: sel.from, to: sel.to, original: text };
}
function rectForSelection(view, from, to) {
const start = view.coordsAtPos(from);
const end = view.coordsAtPos(to);
if (!start || !end) return null;
return {
top: Math.min(start.top, end.top),
bottom: Math.max(start.bottom, end.bottom),
left: Math.min(start.left, end.left),
width: Math.max(80, Math.abs(end.left - start.left)),
right: Math.max(start.right, end.right),
};
}
function showForSelection() {
const view = getEditorView();
if (!view) return false;
const sel = getSelectionFromView();
if (!sel) return false;
if (sel.original.length === 0 || sel.original.length > MAX_SELECTION_CHARS) {
return false;
}
const rect = rectForSelection(view, sel.from, sel.to);
if (!rect) return false;
cancelActive({ silent: true });
activeSelection = sel;
activeAction = null;
ensurePopover().show({ text: sel.original, rect });
return true;
}
async function runAssist(action, selectionText) {
const view = getEditorView();
if (!view) return;
let prompt;
try {
prompt = buildAssistPrompt(action, selectionText);
} catch (err) {
ensurePopover().setState('error', { message: err.message || 'Invalid selection.' });
return;
}
// First-use confirmation — only on the first action of the session,
// and only when the renderer supplied a confirmFirstUse callback.
if (!firstUseConfirmed && typeof confirmFirstUse === 'function') {
const providerLabel =
typeof getProviderLabel === 'function' ? getProviderLabel() : 'your configured AI provider';
const ok = confirmFirstUse({
providerLabel,
selectionLength: selectionText.length,
action,
});
if (!ok) {
ensurePopover().setState('error', {
message: 'Cancelled — first-use confirmation declined.',
});
return;
}
firstUseConfirmed = true;
}
activeAction = action;
ensurePopover().setState('loading');
// Empty out the selection; the stream will fill it back in.
const insertAt = activeSelection ? activeSelection.from : view.state.selection.main.from;
view.dispatch({
changes: { from: insertAt, to: activeSelection?.to ?? insertAt, insert: '' },
selection: { anchor: insertAt },
});
const requestId = `assist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
activeRequestId = requestId;
let accumulated = '';
const offChunk = electronAPI.on('ai-assist-stream:chunk', (_e, payload) => {
if (!payload || payload.requestId !== activeRequestId) return;
accumulated += payload.chunk || '';
view.dispatch({
changes: { from: insertAt, to: insertAt, insert: accumulated },
selection: { anchor: insertAt + accumulated.length },
});
});
const offDone = electronAPI.on('ai-assist-stream:done', (_e, payload) => {
if (!payload || payload.requestId !== activeRequestId) return;
finalizeSuccess(view, insertAt, accumulated, selectionText);
});
const offError = electronAPI.on('ai-assist-stream:error', (_e, payload) => {
if (!payload || payload.requestId !== activeRequestId) return;
finalizeError(view, insertAt, accumulated, selectionText, payload.message);
});
unsubscribers.push(offChunk, offDone, offError);
electronAPI.aiAssist.start(requestId, {
system: prompt.system,
messages: prompt.messages,
});
}
function finalizeSuccess(view, insertAt, accumulated, selectionText) {
const finalText = applyAssistResult(selectionText, accumulated);
cleanupListeners();
if (finalText === null) {
// No-op (empty / unchanged) — restore the original selection
view.dispatch({
changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText },
selection: { anchor: insertAt + selectionText.length },
});
} else {
view.dispatch({
changes: { from: insertAt, to: insertAt + accumulated.length, insert: finalText },
selection: { anchor: insertAt + finalText.length },
});
}
ensurePopover().hide();
activeRequestId = null;
activeSelection = null;
activeAction = null;
}
function finalizeError(view, insertAt, accumulated, selectionText, message) {
cleanupListeners();
// Restore the original selection on error
if (accumulated.length > 0) {
view.dispatch({
changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText },
selection: { anchor: insertAt + selectionText.length },
});
}
ensurePopover().setState('error', { message: message || 'AI request failed.' });
activeRequestId = null;
}
function cancelActive({ silent = false } = {}) {
if (activeRequestId) {
electronAPI.aiAssist.cancel(activeRequestId);
}
cleanupListeners();
const view = getEditorView();
if (view && activeSelection) {
// Restore the original if we cleared the selection
const sel = view.state.selection.main;
if (sel.empty) {
view.dispatch({
changes: { from: activeSelection.from, to: sel.anchor, insert: activeSelection.original },
selection: { anchor: activeSelection.from + activeSelection.original.length },
});
}
}
if (popover && !silent) popover.hide();
activeRequestId = null;
activeSelection = null;
activeAction = null;
}
function cleanupListeners() {
for (const off of unsubscribers) {
try {
off();
} catch {
// ignore
}
}
unsubscribers = [];
}
function detach() {
cancelActive({ silent: true });
if (popover) {
popover.destroy();
popover = null;
}
}
// Lightweight keymap helper. Returns true if the event was handled.
function handleKey(e) {
// Esc while popover is open → cancel
if (e.key === 'Escape' && popover && popover.isVisible()) {
e.preventDefault();
cancelActive();
return true;
}
// Cmd+K / Ctrl+K → open popover for current selection
const meta = e.metaKey || e.ctrlKey;
if (meta && (e.key === 'k' || e.key === 'K')) {
if (typeof onShortcut === 'function' && !onShortcut('cmd-k', e)) return false;
e.preventDefault();
return showForSelection();
}
return false;
}
return {
showForSelection,
cancelActive,
handleKey,
detach,
};
}
module.exports = { createInlineAiController };
+142
View File
@@ -0,0 +1,142 @@
/**
* Sidebar panel: daily notes journal browser.
*
* Lists the YYYY-MM-DD.md files in <userData>/notes/daily/ newest-first,
* lets the user click one to open it, and surfaces a "Today" button that
* creates/opens today's note. Pure DOM module — same pattern as
* snippets-panel.js / search-panel.js.
*
* @param {HTMLElement} container
* @param {object} deps
* @param {() => Promise<Array<{docPath:string, savedAt:number, byteSize:number, appVersion:string}>>} [deps.listPendingRecoveries]
* @param {(args:{date?:string}) => Promise<{path:string, content:string, created:boolean}>} deps.openToday
* @param {() => Promise<string[]>} deps.listExisting
* @param {(filePath:string) => void} deps.onOpenFile
* @param {(filePath:string) => Promise<void>} [deps.deleteEntry] optional — deletes a daily note
*/
function renderDailyNotesPanel(container, deps) {
const { openToday, listExisting, onOpenFile } = deps;
let entries = []; // [{ path, name }]
container.innerHTML = `
<div class="daily-notes-panel">
<div class="daily-notes-toolbar">
<button class="daily-notes-today-btn" id="daily-notes-today">Today</button>
<button class="daily-notes-refresh-btn" id="daily-notes-refresh" title="Reload list">↻</button>
</div>
<div class="daily-notes-status" id="daily-notes-status"></div>
<div class="daily-notes-list" id="daily-notes-list"></div>
</div>
`;
const todayBtn = container.querySelector('#daily-notes-today');
const refreshBtn = container.querySelector('#daily-notes-refresh');
const statusEl = container.querySelector('#daily-notes-status');
const listEl = container.querySelector('#daily-notes-list');
function basename(p) {
return String(p).split(/[/\\]/).pop();
}
function statusOf(name) {
// YYYY-MM-DD.md → "Sun, 13 Sep 2026" (or local equivalent)
const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(name);
if (!m) return name;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (Number.isNaN(d.getTime())) return name;
return d.toLocaleDateString(undefined, {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function renderList() {
if (!listEl) return;
if (!entries || entries.length === 0) {
listEl.innerHTML =
'<div class="daily-notes-empty">No daily notes yet. Press <b>Today</b> to start.</div>';
return;
}
listEl.innerHTML = entries
.map((entry, i) => {
return `<div class="daily-notes-item" role="button" tabindex="0" data-idx="${i}">
<div class="daily-notes-name"></div>
<div class="daily-notes-sub"></div>
</div>`;
})
.join('');
// Set dynamic content via textContent so a malicious filename can't
// inject HTML.
listEl.querySelectorAll('.daily-notes-item').forEach((el) => {
const idx = Number(el.dataset.idx);
const entry = entries[idx];
el.querySelector('.daily-notes-name').textContent = entry.name.replace(/\.md$/, '');
el.querySelector('.daily-notes-sub').textContent = statusOf(entry.name);
el.addEventListener('click', () => {
if (onOpenFile) onOpenFile(entry.path);
});
el.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
el.click();
}
});
});
}
async function refresh({ keepStatus = false } = {}) {
if (typeof listExisting !== 'function') {
setStatus('Daily notes list is unavailable.');
return;
}
if (!keepStatus) setStatus('Loading…');
try {
const list = await listExisting();
// listExisting returns absolute paths (newest first). Pair each path
// with its basename for display.
const paths = Array.isArray(list) ? list : [];
entries = paths.filter((p) => /\.md$/i.test(p)).map((p) => ({ path: p, name: basename(p) }));
renderList();
if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`
);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
renderList();
}
}
todayBtn.addEventListener('click', async () => {
setStatus('Opening today…');
todayBtn.disabled = true;
try {
const result = await openToday({});
if (result && result.path && onOpenFile) onOpenFile(result.path);
setStatus(result && result.created ? 'Created today.' : 'Today already exists.');
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
} finally {
todayBtn.disabled = false;
}
});
refreshBtn.addEventListener('click', () => {
refresh();
});
refresh();
return {
refresh,
basename,
};
}
module.exports = { renderDailyNotesPanel };
+178
View File
@@ -0,0 +1,178 @@
/**
* Sidebar panel: daily-note template gallery.
*
* Lists every .md in <userData>/notes/templates/, lets the user pick one
* before opening today's daily note, and exposes create/edit/delete.
*
* Pure DOM module — same pattern as the other sidebar panels.
*
* @param {HTMLElement} container
* @param {object} deps
* @param {() => Promise<Array<{name:string,label:string,content:string}>>} deps.listTemplates
* @param {({name:string, content:string}) => Promise<{name:string,label:string,content:string}>} deps.saveTemplate
* @param {({name:string}) => Promise<boolean>} deps.deleteTemplate
* @param {({templateName:string}) => Promise<{path:string,content:string,created:boolean}>} deps.applyTemplate
* @param {(filePath:string) => void} deps.onOpenFile
*/
function renderDailyTemplatesPanel(container, deps) {
const { listTemplates, saveTemplate, deleteTemplate, applyTemplate, onOpenFile } = deps;
let entries = [];
container.innerHTML = `
<div class="daily-templates-panel">
<div class="daily-templates-toolbar">
<button class="daily-templates-new-btn" id="daily-templates-new">+ New Template</button>
<button class="daily-templates-refresh-btn" id="daily-templates-refresh" title="Reload">↻</button>
</div>
<div class="daily-templates-status" id="daily-templates-status"></div>
<div class="daily-templates-list" id="daily-templates-list"></div>
</div>
`;
const newBtn = container.querySelector('#daily-templates-new');
const refreshBtn = container.querySelector('#daily-templates-refresh');
const statusEl = container.querySelector('#daily-templates-status');
const listEl = container.querySelector('#daily-templates-list');
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function renderList() {
if (!listEl) return;
if (!entries || entries.length === 0) {
listEl.innerHTML =
'<div class="daily-templates-empty">No templates yet. Press <b>+ New Template</b> to create one.</div>';
return;
}
listEl.innerHTML = entries
.map(
(t, i) =>
`<div class="daily-templates-item" data-idx="${i}">
<div class="daily-templates-row">
<div class="daily-templates-label"></div>
<div class="daily-templates-actions">
<button class="daily-templates-apply-btn" data-action="apply" data-idx="${i}" title="Use this template for today's note">Use</button>
<button class="daily-templates-delete-btn" data-action="delete" data-idx="${i}" title="Delete template">×</button>
</div>
</div>
</div>`
)
.join('');
listEl.querySelectorAll('.daily-templates-item').forEach((el) => {
const idx = Number(el.dataset.idx);
const t = entries[idx];
el.querySelector('.daily-templates-label').textContent = t.label;
});
listEl.querySelectorAll('[data-action="apply"]').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const idx = Number(btn.dataset.idx);
const t = entries[idx];
await useTemplate(t);
});
});
listEl.querySelectorAll('[data-action="delete"]').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const idx = Number(btn.dataset.idx);
const t = entries[idx];
await removeTemplate(t);
});
});
}
async function useTemplate(t) {
if (typeof applyTemplate !== 'function') {
setStatus('Template application is unavailable.');
return;
}
setStatus(`Using "${t.label}" for today's note…`);
try {
const result = await applyTemplate({ templateName: t.name });
if (result && result.path && onOpenFile) onOpenFile(result.path);
setStatus(result && result.created ? 'Created today.' : 'Today already exists.');
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
}
async function removeTemplate(t) {
// Don't allow deleting the only template — would break the daily-notes
// default. The user can add more first.
if (entries.length <= 1) {
setStatus('Keep at least one template (the default daily.md).');
return;
}
if (typeof deleteTemplate !== 'function') return;
try {
await deleteTemplate({ name: t.name });
setStatus(`Deleted "${t.label}".`);
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
}
function promptForName(defaultName) {
// Vanilla prompt is sufficient here — this is a small modal one-liner.
const answer = window.prompt('Template file name (.md added if missing):', defaultName);
if (typeof answer !== 'string') return null;
const trimmed = answer.trim();
if (!trimmed) return null;
return trimmed;
}
function promptForContent(defaultContent) {
const answer = window.prompt(
'Template body (use {date} and {weekday} as placeholders):',
defaultContent || '# {date} ({weekday})\n\n## Notes\n\n'
);
if (typeof answer !== 'string') return null;
return answer;
}
newBtn.addEventListener('click', async () => {
if (typeof saveTemplate !== 'function') return;
const name = promptForName('morning-pages.md');
if (!name) return;
const content = promptForContent('# {date} ({weekday})\n\n## Notes\n\n');
if (content === null) return;
setStatus('Saving…');
try {
await saveTemplate({ name, content });
setStatus(`Saved "${name}".`);
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
}
});
refreshBtn.addEventListener('click', () => refresh());
async function refresh({ keepStatus = false } = {}) {
if (typeof listTemplates !== 'function') {
setStatus('Template list is unavailable.');
return;
}
if (!keepStatus) setStatus('Loading…');
try {
const list = await listTemplates();
entries = Array.isArray(list) ? list : [];
renderList();
if (!keepStatus)
setStatus(
entries.length === 0 ? '' : `${entries.length} template${entries.length === 1 ? '' : 's'}`
);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
entries = [];
renderList();
}
}
refresh();
return { refresh };
}
module.exports = { renderDailyTemplatesPanel };
+290
View File
@@ -0,0 +1,290 @@
/**
* Sidebar panel: visual flow chart editor.
*
* Owns:
* - flowchart-store (graph + undo/redo + subscribe)
* - flowchart-canvas (SVG)
* - flowchart-mermaid preview (text + injected renderer)
* - debounced persistence to <userData>/flowchart-session.json
* - panel-scoped keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z / Delete)
* - "Insert at Cursor" button (reuses the existing `insert-content` IPC)
* - "Maximize / Restore" toggle that promotes the panel to fill the
* main-content area (hides the editor-container) so the canvas +
* preview split get the full window width instead of the 280px sidebar.
*
* @param {HTMLElement} container Mount point inside the sidebar panel
* @param {object} deps
* @param {() => string} deps.getUserDataPath Absolute userData directory (renderer pre-resolves and caches this on first mount — must be synchronous and return a real string, not a Promise)
* @param {(path:string) => Promise<string|null>} deps.readFile
* @param {(path:string, content:string) => Promise<void>} deps.writeFile
* @param {(text:string) => void} deps.insertAtCursor
* @param {(source:string, target:HTMLElement) => void} [deps.renderMermaid]
*/
'use strict';
const { create: createStore } = require('../flowchart/flowchart-store');
const { createCanvas } = require('../flowchart/flowchart-canvas');
const { toMermaid } = require('../flowchart/flowchart-mermaid');
const PREVIEW_DEBOUNCE_MS = 250;
const PERSIST_DEBOUNCE_MS = 500;
const PERSISTENCE_FILENAME = 'flowchart-session.json';
// CSS class toggled on `.main-content` while the panel is in takeover mode.
// Kept colocated with the panel so any reader can grep for it. See
// src/styles-sidebar.css `.main-content.flowchart-takeover` rules.
const TAKEOVER_CLASS = 'flowchart-takeover';
function renderFlowChartPanel(container, deps) {
const { getUserDataPath, readFile, writeFile, insertAtCursor, renderMermaid = () => {} } = deps;
if (typeof getUserDataPath !== 'function') {
throw new Error('flowchart-panel: getUserDataPath is required');
}
if (typeof readFile !== 'function' || typeof writeFile !== 'function') {
throw new Error('flowchart-panel: readFile and writeFile are required');
}
if (typeof insertAtCursor !== 'function') {
throw new Error('flowchart-panel: insertAtCursor is required');
}
// Compute the persistence path ONCE on mount. Re-resolving per call would
// hit the filesystem / IPC bridge unnecessarily on every debounced write.
const persistenceFile = `${getUserDataPath()}/${PERSISTENCE_FILENAME}`;
container.innerHTML = `
<div class="flowchart-panel" tabindex="0">
<div class="flowchart-toolbar">
<button class="flowchart-insert-btn" title="Insert Mermaid block at cursor">
Insert at Cursor
</button>
<span class="flowchart-status" aria-live="polite"></span>
<button
class="flowchart-maximize-btn"
title="Maximize: hide the editor and let the canvas + preview fill the main area"
aria-label="Maximize Flow Chart panel"
>
<span class="flowchart-maximize-icon" aria-hidden="true">⤢</span>
<span class="flowchart-maximize-label">Maximize</span>
</button>
</div>
<div class="flowchart-split">
<div class="flowchart-canvas-host"></div>
<div class="flowchart-preview-host">
<pre class="flowchart-preview-source"></pre>
<div class="flowchart-preview-render"></div>
</div>
</div>
</div>
`;
const canvasHost = container.querySelector('.flowchart-canvas-host');
const previewSourceEl = container.querySelector('.flowchart-preview-source');
const previewRenderEl = container.querySelector('.flowchart-preview-render');
const insertBtn = container.querySelector('.flowchart-insert-btn');
const statusEl = container.querySelector('.flowchart-status');
const maximizeBtn = container.querySelector('.flowchart-maximize-btn');
const maximizeLabel = container.querySelector('.flowchart-maximize-label');
let selectedNodeId = null;
let selectedEdgeId = null;
// Takeover state: when true, `.main-content` carries `flowchart-takeover`
// and the editor-container is hidden so the panel + canvas + preview split
// the full window width. Toggled by the maximize button (and cleaned up
// by destroy() so leaving it doesn't leave the editor hidden).
let takeoverActive = false;
// The sidebar lives inside `.main-content` (see src/styles-sidebar.css).
// Look it up by walking up from the panel container — that way the panel
// doesn't need to know whether the renderer mounted it via #sidebar or
// any future container.
function findMainContent() {
let el = container;
while (el && el.parentElement) {
el = el.parentElement;
if (el.classList && el.classList.contains('main-content')) return el;
}
return document.querySelector('.main-content');
}
const store = createStore({
persistencePath: persistenceFile,
readFile,
writeFile,
now: () => Date.now(),
});
// Hydrate from disk (defensively).
readFile(persistenceFile)
.then((json) => {
if (json) store.deserialize(json);
})
.catch((err) => {
console.warn('flowchart-panel: failed to read session', err);
});
const canvas = createCanvas(canvasHost, store, {
onEdgeClick: (edgeId) => {
// Click an edge → prompt for kind and (optional) label. v2 can replace
// this with a real popover menu; the prompts are intentionally simple.
// Mirror the selection into panel state so Delete/Backspace on the
// panel-scoped keydown handler routes to the right entity even after a
// pure click (no drag, no store mutation to trigger render-based sync).
selectedEdgeId = edgeId;
selectedNodeId = null;
const edge = store.getGraph().edges.find((e) => e.id === edgeId);
if (!edge) return;
const nextKind = window.prompt('Edge kind (solid, dotted, thick):', edge.kind);
if (nextKind && ['solid', 'dotted', 'thick'].includes(nextKind)) {
store.setEdgeKind(edgeId, nextKind);
}
const nextLabel = window.prompt('Edge label (empty to clear):', edge.label || '');
if (nextLabel !== null) {
store.setEdgeLabel(edgeId, nextLabel);
}
},
onNodeClick: (nodeId) => {
// Canvas → panel selection sync. The canvas already paints the
// .flowchart-node.selected highlight (see flowchart-canvas.js render()
// and the .flowchart-node.selected CSS rule). Mirror the id into the
// panel's selection state so Delete/Backspace routes here.
selectedNodeId = nodeId;
selectedEdgeId = null;
},
onShapeMenu: (nodeId) => {
// Prompt for a new shape kind. v2: replace with a real context menu.
const next = window.prompt(
'New shape (process, decision, terminator, subroutine, document):'
);
if (next) store.setNodeKind(nodeId, next);
},
});
// Debounced live preview. Track the timer handle so destroy() can cancel
// any in-flight update that would otherwise write to a detached <pre>.
let previewTimer = null;
function runPreview() {
const source = toMermaid(store.getGraph());
previewSourceEl.textContent = source;
try {
renderMermaid(source, previewRenderEl);
} catch (err) {
previewRenderEl.textContent = `Preview error: ${err && err.message ? err.message : 'unknown'}`;
}
}
function debouncedPreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
previewTimer = null;
runPreview();
}, PREVIEW_DEBOUNCE_MS);
}
// Debounced persistence. Track the timer handle so destroy() can cancel a
// queued writeFile that would otherwise fire after unmount.
let persistTimer = null;
function debouncedPersist() {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
writeFile(persistenceFile, store.serialize()).catch((err) => {
if (statusEl) statusEl.textContent = `Save failed: ${err.message || err}`;
});
}, PERSIST_DEBOUNCE_MS);
}
const unsubscribeStore = store.subscribe(() => {
debouncedPreview();
debouncedPersist();
});
function onInsertClick() {
const source = toMermaid(store.getGraph());
insertAtCursor('```mermaid\n' + source + '\n```');
}
insertBtn.addEventListener('click', onInsertClick);
// Maximize / restore toggle. When active, .main-content.flowchart-takeover
// hides the editor-container and lets the sidebar + canvas fill the row.
// Click again to restore. Kept inside the panel so its lifecycle matches
// the panel's destroy() cleanup.
function setTakeover(active) {
const mainContent = findMainContent();
if (!mainContent) return;
takeoverActive = active;
mainContent.classList.toggle(TAKEOVER_CLASS, active);
maximizeBtn.classList.toggle('active', active);
maximizeBtn.setAttribute(
'aria-label',
active ? 'Restore Flow Chart panel' : 'Maximize Flow Chart panel'
);
maximizeBtn.setAttribute(
'title',
active
? 'Restore: show the editor again'
: 'Maximize: hide the editor and let the canvas + preview fill the main area'
);
if (maximizeLabel) {
maximizeLabel.textContent = active ? 'Restore' : 'Maximize';
}
}
function onMaximizeClick() {
setTakeover(!takeoverActive);
}
maximizeBtn.addEventListener('click', onMaximizeClick);
// Keyboard shortcuts — panel-scoped.
function onContainerKeyDown(ev) {
if (ev.ctrlKey && !ev.metaKey && ev.key.toLowerCase() === 'z') {
ev.preventDefault();
if (ev.shiftKey) store.redo();
else store.undo();
return;
}
if (ev.key === 'Delete' || ev.key === 'Backspace') {
if (selectedNodeId) {
ev.preventDefault();
store.removeNode(selectedNodeId);
selectedNodeId = null;
} else if (selectedEdgeId) {
ev.preventDefault();
store.disconnect(selectedEdgeId);
selectedEdgeId = null;
}
}
}
container.addEventListener('keydown', onContainerKeyDown);
return {
getStore: () => store,
getSvg: () => canvas.getSvg(),
selectNode: (id) => {
selectedNodeId = id;
},
selectEdge: (id) => {
selectedEdgeId = id;
},
destroy: () => {
// Clear timers BEFORE canvas.destroy(): canvas teardown may trigger a
// last pointer-move that schedules another preview/persist; we want
// those timers cancelled before canvas.destroy() runs.
if (previewTimer) {
clearTimeout(previewTimer);
previewTimer = null;
}
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
unsubscribeStore();
container.removeEventListener('keydown', onContainerKeyDown);
insertBtn.removeEventListener('click', onInsertClick);
maximizeBtn.removeEventListener('click', onMaximizeClick);
// Restore the editor if we were in takeover mode — leaving the class
// on .main-content would hide the editor for the rest of the session.
if (takeoverActive) setTakeover(false);
canvas.destroy();
},
};
}
module.exports = { renderFlowChartPanel };
+226
View File
@@ -0,0 +1,226 @@
/**
* Sidebar panel: workspace content search + doc-aware Q&A.
*
* Two modes, one input box:
* - Search mode (default): enter a query, get ranked file hits. The query
* grammar is parsed on the main process (WorkspaceSearch); the renderer
* just displays what comes back.
* - Ask mode: prefix with "?" (or click the Ask toggle) to switch to
* chunk-level Q&A. Grammar noise (what/how/why/...) is stripped before
* ranking, and the response surfaces individual passages instead of
* whole-file hits.
*
* Click a result to open the file (and optionally jump to the chunk offset
* for Q&A hits). The dir defaults to the same folder the Explorer is
* looking at; the user can override.
*
* @param {HTMLElement} container Mount point inside the sidebar panel
* @param {object} deps
* @param {(args:{query,dir,limit}) => Promise<Array>} deps.search
* @param {(args:{question,dir,topK}) => Promise<{question,chunks}>} deps.ask
* @param {() => string|null} deps.getCurrentDir
* @param {(filePath:string, offset?:number) => void} deps.onOpenFile
*/
function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }) {
let mode = 'search'; // 'search' | 'ask'
let lastResults = [];
container.innerHTML = `
<div class="search-panel">
<div class="search-toolbar">
<div class="search-mode-toggle" role="tablist">
<button class="search-mode-btn active" data-mode="search" role="tab">Search</button>
<button class="search-mode-btn" data-mode="ask" role="tab">Ask</button>
</div>
</div>
<div class="search-input-row">
<input type="text" class="search-input" id="search-input"
placeholder="Search notes — try: rust #lang @project-x &quot;exact phrase&quot;"
autocomplete="off">
<button class="search-run-btn" id="search-run" title="Run (Enter)">&#x1F50D;</button>
</div>
<div class="search-dir-row">
<input type="text" class="search-dir" id="search-dir"
placeholder="Folder (defaults to current)"
value="" readonly>
<button class="search-clear-btn" id="search-clear" title="Clear results">Clear</button>
</div>
<div class="search-status" id="search-status"></div>
<div class="search-results" id="search-results"></div>
</div>
`;
const inputEl = container.querySelector('#search-input');
const runBtn = container.querySelector('#search-run');
const dirEl = container.querySelector('#search-dir');
const clearBtn = container.querySelector('#search-clear');
const statusEl = container.querySelector('#search-status');
const resultsEl = container.querySelector('#search-results');
const modeBtns = container.querySelectorAll('.search-mode-btn');
// Seed the dir from the explorer when the panel first opens
const initialDir = getCurrentDir && getCurrentDir();
if (initialDir) dirEl.value = initialDir;
else dirEl.placeholder = 'Folder (none — open one in Explorer first)';
function setMode(next) {
mode = next;
modeBtns.forEach((b) => b.classList.toggle('active', b.dataset.mode === next));
inputEl.placeholder =
next === 'search'
? 'Search notes — try: rust #lang @project-x "exact phrase"'
: 'Ask a question — try: what did I write about rust async';
}
modeBtns.forEach((b) => {
b.addEventListener('click', () => {
setMode(b.dataset.mode);
inputEl.focus();
});
});
function setStatus(text, kind = 'info') {
if (!statusEl) return;
statusEl.textContent = text || '';
statusEl.dataset.kind = kind;
}
function basename(p) {
if (typeof p !== 'string') return '';
return p.split(/[/\\]/).pop() || p;
}
function escapeHtml(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
function renderResults(items) {
if (!resultsEl) return;
if (!items || items.length === 0) {
resultsEl.innerHTML = '<div class="search-empty">No matches.</div>';
return;
}
resultsEl.innerHTML = items
.map((r, i) => {
const tagList = (r.matchedTags || []).map((t) => `#${t}`).join(' ');
const linkList = (r.matchedLinks || []).map((l) => `[[${l}]]`).join(' ');
const tagsHtml = tagList
? `<div class="search-result-tags">${escapeHtml(tagList)}</div>`
: '';
const linksHtml = linkList
? `<div class="search-result-links">${escapeHtml(linkList)}</div>`
: '';
const meta = [
r.score !== undefined ? `score ${Number(r.score).toFixed(1)}` : '',
r.offset ? `+${r.offset}` : '',
r.matchedTerms && r.matchedTerms.length ? r.matchedTerms.join(', ') : '',
]
.filter(Boolean)
.join(' · ');
return `
<div class="search-result" data-idx="${i}" role="button" tabindex="0">
<div class="search-result-name">${escapeHtml(basename(r.filePath))}</div>
<div class="search-result-path">${escapeHtml(r.filePath)}</div>
<div class="search-result-snippet">${escapeHtml(r.snippet || '')}</div>
${tagsHtml}${linksHtml}
<div class="search-result-meta">${escapeHtml(meta)}</div>
</div>
`;
})
.join('');
resultsEl.querySelectorAll('.search-result').forEach((el) => {
el.addEventListener('click', () => {
const r = lastResults[Number(el.dataset.idx)];
if (r && onOpenFile) onOpenFile(r.filePath, r.offset || 0);
});
el.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
const r = lastResults[Number(el.dataset.idx)];
if (r && onOpenFile) onOpenFile(r.filePath, r.offset || 0);
}
});
});
}
async function runQuery() {
const q = inputEl.value.trim();
if (!q) {
setStatus('Enter a query to search.', 'info');
lastResults = [];
renderResults([]);
return;
}
const dir = dirEl.value.trim() || (getCurrentDir && getCurrentDir()) || '';
if (!dir) {
setStatus('No folder selected — open one in the Explorer first.', 'error');
return;
}
setStatus('Searching…', 'working');
runBtn.disabled = true;
try {
if (mode === 'ask') {
const r = await ask({ question: q, dir, topK: 5 });
lastResults = (r && r.chunks) || [];
setStatus(
`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`
);
} else {
const r = await search({ query: q, dir, limit: 50 });
lastResults = Array.isArray(r) ? r : [];
setStatus(`Searched — ${lastResults.length} match${lastResults.length === 1 ? '' : 'es'}`);
}
renderResults(lastResults);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`, 'error');
lastResults = [];
renderResults([]);
} finally {
runBtn.disabled = false;
}
}
runBtn.addEventListener('click', runQuery);
inputEl.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
runQuery();
} else if (ev.key === 'Escape') {
inputEl.value = '';
lastResults = [];
renderResults([]);
setStatus('');
}
});
clearBtn.addEventListener('click', () => {
inputEl.value = '';
lastResults = [];
renderResults([]);
setStatus('');
inputEl.focus();
});
// Expose a small API so the host (renderer) can prefill the dir when the
// Explorer navigates to a new folder.
return {
setDir(d) {
if (typeof d === 'string') dirEl.value = d;
},
focus() {
inputEl.focus();
},
clear() {
inputEl.value = '';
lastResults = [];
renderResults([]);
setStatus('');
},
};
}
module.exports = { renderSearchPanel };
-315
View File
@@ -358,72 +358,6 @@ body.theme-concreteinfo .status-bar {
font-family: 'SFMono-Regular', Consolas, monospace; font-family: 'SFMono-Regular', Consolas, monospace;
} }
/* Dark theme header adjustments */
body.theme-dark .app-header,
body.theme-dracula .app-header,
body.theme-onedark .app-header,
body.theme-tokyonight .app-header,
body.theme-monokai .app-header,
body.theme-cobalt2 .app-header,
body.theme-gruvbox-dark .app-header,
body.theme-ayu-dark .app-header,
body.theme-ayu-mirage .app-header,
body.theme-palenight .app-header,
body.theme-oceanic-next .app-header,
body.theme-nord .app-header,
body.theme-concrete-dark .app-header {
background: #1f2937;
border-bottom-color: #374151;
}
body.theme-dark .app-title,
body.theme-dracula .app-title,
body.theme-onedark .app-title,
body.theme-tokyonight .app-title,
body.theme-monokai .app-title,
body.theme-cobalt2 .app-title,
body.theme-gruvbox-dark .app-title,
body.theme-ayu-dark .app-title,
body.theme-ayu-mirage .app-title,
body.theme-palenight .app-title,
body.theme-oceanic-next .app-title,
body.theme-nord .app-title,
body.theme-concrete-dark .app-title {
color: #f3f4f6;
}
body.theme-dark .app-version,
body.theme-dracula .app-version,
body.theme-onedark .app-version,
body.theme-tokyonight .app-version,
body.theme-monokai .app-version,
body.theme-cobalt2 .app-version,
body.theme-gruvbox-dark .app-version,
body.theme-ayu-dark .app-version,
body.theme-ayu-mirage .app-version,
body.theme-palenight .app-version,
body.theme-oceanic-next .app-version,
body.theme-nord .app-version,
body.theme-concrete-dark .app-version {
color: #9ca3af;
}
body.theme-dark .app-logo,
body.theme-dracula .app-logo,
body.theme-onedark .app-logo,
body.theme-tokyonight .app-logo,
body.theme-monokai .app-logo,
body.theme-cobalt2 .app-logo,
body.theme-gruvbox-dark .app-logo,
body.theme-ayu-dark .app-logo,
body.theme-ayu-mirage .app-logo,
body.theme-palenight .app-logo,
body.theme-oceanic-next .app-logo,
body.theme-nord .app-logo,
body.theme-concrete-dark .app-logo {
filter: none;
}
/* ============================================ /* ============================================
Converter Cards Converter Cards
============================================ */ ============================================ */
@@ -734,255 +668,6 @@ body.theme-concreteinfo-dark .modal-footer {
} }
} }
/* ============================================
Concrete Dark Theme
============================================ */
body.theme-concrete-dark {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .toolbar {
background: linear-gradient(135deg, #0d0b09 0%, #1a1a1a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-dark .toolbar button {
color: #e3e3e3;
}
body.theme-concrete-dark .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-dark .tab-bar {
background: #0d0b09;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .tab {
background: #2a2a2a;
color: #e3e3e3;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-dark .tab.active {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .editor-textarea,
body.theme-concrete-dark .editor-pane {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .preview-content {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .status-bar {
background: #0d0b09;
color: #9a9696;
border-top: 1px solid #464646;
}
body.theme-concrete-dark .export-dialog-content,
body.theme-concrete-dark .batch-dialog-content {
background: #2a2a2a;
color: #e3e3e3;
}
body.theme-concrete-dark .export-dialog-header,
body.theme-concrete-dark .batch-dialog-header {
background: #1a1a1a;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .export-dialog-footer,
body.theme-concrete-dark .batch-dialog-footer {
background: #1a1a1a;
}
body.theme-concrete-dark input,
body.theme-concrete-dark select,
body.theme-concrete-dark textarea {
background: #1a1a1a;
color: #e3e3e3;
border-color: #464646;
}
body.theme-concrete-dark input:focus,
body.theme-concrete-dark select:focus,
body.theme-concrete-dark textarea:focus {
border-color: #e5461f;
}
body.theme-concrete-dark .line-numbers {
background: #0d0b09;
color: #9a9696;
}
/* ============================================
Concrete Light Theme
============================================ */
body.theme-concrete-light {
background: #f5f5f5;
color: #464646;
}
body.theme-concrete-light .toolbar {
background: #ffffff;
border-bottom: 3px solid #e5461f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
body.theme-concrete-light .toolbar button {
color: #464646;
}
body.theme-concrete-light .toolbar button:hover {
background: #e5461f;
color: #ffffff;
}
body.theme-concrete-light .tab-bar {
background: #ffffff;
border-bottom: 2px solid #e3e3e3;
}
body.theme-concrete-light .tab {
background: #f5f5f5;
color: #464646;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-light .tab.active {
background: #ffffff;
border-bottom: 2px solid #e5461f;
color: #e5461f;
}
body.theme-concrete-light .editor-textarea,
body.theme-concrete-light .editor-pane {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .preview-content {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .status-bar {
background: #ffffff;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-light .export-dialog-content,
body.theme-concrete-light .batch-dialog-content {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .export-dialog-header,
body.theme-concrete-light .batch-dialog-header {
background: #f5f5f5;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-light .line-numbers {
background: #f5f5f5;
color: #9a9696;
}
/* ============================================
Concrete Warm Theme
============================================ */
body.theme-concrete-warm {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .toolbar {
background: linear-gradient(135deg, #464646 0%, #5a5a5a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-warm .toolbar button {
color: #faf8f5;
}
body.theme-concrete-warm .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-warm .tab-bar {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .tab {
background: #6a6a6a;
color: #faf8f5;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-warm .tab.active {
background: #faf8f5;
color: #e5461f;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .editor-textarea,
body.theme-concrete-warm .editor-pane {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .preview-content {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .status-bar {
background: #f0ebe4;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-warm .export-dialog-content,
body.theme-concrete-warm .batch-dialog-content {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .export-dialog-header,
body.theme-concrete-warm .batch-dialog-header {
background: #f0ebe4;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm h1,
body.theme-concrete-warm h2,
body.theme-concrete-warm h3,
body.theme-concrete-warm h4,
body.theme-concrete-warm h5,
body.theme-concrete-warm h6 {
color: #e5461f;
}
body.theme-concrete-warm a {
color: #e5461f;
}
body.theme-concrete-warm .line-numbers {
background: #f0ebe4;
color: #9a9696;
}
/* Monospace font + ligature toggles — driven by body classes from renderer */ /* Monospace font + ligature toggles — driven by body classes from renderer */
body.mono-jetbrains { body.mono-jetbrains {
--font-mono-active: 'JetBrains Mono', monospace; --font-mono-active: 'JetBrains Mono', monospace;
File diff suppressed because it is too large Load Diff
+187
View File
@@ -1023,3 +1023,190 @@ body[class*='dark'] .preview-content a.wiki-link {
color: var(--text-secondary, inherit); color: var(--text-secondary, inherit);
opacity: 0.75; opacity: 0.75;
} }
/* === Flow Chart Editor panel === */
.flowchart-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
outline: none;
}
.flowchart-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-color, #444);
}
.flowchart-insert-btn {
padding: 4px 10px;
border: 1px solid var(--accent, #4a9eff);
background: var(--accent, #4a9eff);
color: var(--accent-fg, #fff);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.flowchart-status {
margin-left: auto;
font-size: 11px;
color: var(--text-muted, #888);
}
.flowchart-split {
display: flex;
flex: 1;
min-height: 0;
}
.flowchart-canvas-host {
flex: 7;
min-width: 0;
position: relative;
background: var(--bg-secondary, #1e1e1e);
}
.flowchart-canvas-host svg.flowchart-canvas {
width: 100%;
height: 100%;
display: block;
cursor: crosshair;
}
.flowchart-preview-host {
flex: 3;
min-width: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--border-color, #444);
}
.flowchart-preview-source {
flex: 1;
margin: 0;
padding: 8px;
font-family: var(--mono-font, monospace);
font-size: 11px;
overflow: auto;
white-space: pre;
border-bottom: 1px solid var(--border-color, #444);
}
.flowchart-preview-render {
flex: 1;
/* Non-zero CSS height guarantees the Mermaid-rendered SVG has room to lay
* out — without this, flex children in a collapsed parent can compute to 0
* height and mermaid's <svg> ends up invisible even though it parsed and
* attached to the DOM. */
min-height: 120px;
padding: 8px;
overflow: auto;
}
/* v4.9.5 — force light, readable surfaces inside the flowchart canvas and
* preview regardless of the project's theme class on <body>. The flowchart
* is a focused editor surface (similar to the PDF viewer) and the previous
* "inherit body theme" approach left users with dark-on-dark rectangles and
* dark-on-dark Mermaid SVG output, both effectively invisible. Keeping this
* surface light trades theme consistency for guaranteed visibility — the
* user has been explicit that WORKING > consistent with body theme. */
.flowchart-canvas-host,
.flowchart-preview-host {
background: #fafafa !important;
color: #1f2328 !important;
}
.flowchart-node rect,
.flowchart-node polygon {
fill: #ffffff !important;
stroke: #1f2328 !important;
stroke-width: 1.25;
}
.flowchart-node text {
fill: #1f2328 !important;
}
.flowchart-edge {
stroke: #1f2328 !important;
stroke-width: 1.25;
}
.flowchart-node {
cursor: grab;
}
/* v4.9.5 — selection highlight: prior to this rule set, `.selected` only
* painted a 2px stroke on a near-black fill — barely visible against the
* dark canvas. Add a fill change AND bump stroke-width so the highlight is
* unmissable. Same logic for edges. */
.flowchart-node.selected rect,
.flowchart-node.selected polygon {
fill: #e3f0ff !important;
stroke: #4a9eff !important;
stroke-width: 3;
}
.flowchart-edge.selected {
stroke: #4a9eff !important;
stroke-width: 3;
}
.flowchart-label-input {
font-size: 13px;
text-align: center;
border: 1px solid var(--accent, #4a9eff);
background: var(--bg-primary, #fff);
color: inherit;
z-index: 100;
}
/* Takeover mode — the Flow Chart panel promotes itself to fill the main
* area, hiding the editor-container so the canvas + preview split the full
* window width instead of the 280px sidebar. Toggled by the maximize
* button in src/sidebar/flowchart-panel.js (adds/removes
* `.flowchart-takeover` on `.main-content`). Restore on second click or on
* panel destroy(). */
.main-content.flowchart-takeover .editor-container {
display: none;
}
.main-content.flowchart-takeover .sidebar {
width: auto;
flex: 1;
}
.main-content.flowchart-takeover .sidebar:not(.collapsed) {
/* The takeover is only meaningful with the flowchart panel open, so the
* 48px collapsed-state width doesn't apply. Force the wide layout. */
width: auto;
}
.main-content.flowchart-takeover .sidebar-panel {
flex: 1;
width: auto;
}
.main-content.flowchart-takeover .sidebar-panel-content {
padding: 0;
}
/* The panel itself should fill the panel-content column instead of being
* constrained by the normal 12px padding-block flow. */
.main-content.flowchart-takeover .flowchart-panel {
height: 100%;
}
/* Maximize button — small icon+label pill in the panel toolbar. */
.flowchart-maximize-btn {
margin-left: 8px;
padding: 4px 10px;
border: 1px solid var(--border-color, #444);
background: var(--bg-primary, #fff);
color: inherit;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.flowchart-maximize-btn:hover {
border-color: var(--accent, #4a9eff);
color: var(--accent, #4a9eff);
}
.flowchart-maximize-btn:focus-visible {
outline: 2px solid var(--accent, #4a9eff);
outline-offset: 2px;
}
.flowchart-maximize-btn.active {
background: var(--accent, #4a9eff);
border-color: var(--accent, #4a9eff);
color: var(--accent-fg, #fff);
}
.flowchart-maximize-icon {
font-size: 14px;
line-height: 1;
}
-1855
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Atom One Light (Default) — id=atomonelight — category=light */
body.theme-atomonelight {
background: #fafafa;
color: #383a42;
}
body.theme-atomonelight .tab-bar {
background: #f0f0f0;
border-bottom-color: #e0e0e0;
}
body.theme-atomonelight .tab {
background: #e5e5e6;
border-color: #d0d0d0;
color: #383a42;
}
body.theme-atomonelight .tab.active {
background: #fafafa;
border-color: #4078f2;
}
body.theme-atomonelight .toolbar {
background: #f0f0f0;
border-bottom-color: #e0e0e0;
}
body.theme-atomonelight .toolbar button {
color: #383a42;
}
body.theme-atomonelight .toolbar button:hover {
background: #e5e5e6;
border-color: #d0d0d0;
}
body.theme-atomonelight .editor-textarea {
background: #fafafa;
color: #383a42;
}
body.theme-atomonelight .pane:last-child {
background: #fafafa;
}
body.theme-atomonelight .preview-content {
color: #383a42;
}
body.theme-atomonelight .preview-content h1,
body.theme-atomonelight .preview-content h2 {
border-bottom-color: #e0e0e0;
color: #4078f2;
}
body.theme-atomonelight .preview-content code {
background-color: #f0f0f0;
}
body.theme-atomonelight .preview-content pre {
background-color: #f0f0f0;
}
body.theme-atomonelight .preview-content a {
color: #0184bc;
}
body.theme-atomonelight .status-bar {
background: #f0f0f0;
border-top-color: #e0e0e0;
color: #383a42;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Ayu Dark — id=ayu-dark — category=dark */
body.theme-ayu-dark {
background: #0a0e14;
color: #b3b1ad;
}
body.theme-ayu-dark .tab-bar {
background: #0d1016;
border-bottom-color: #1f2430;
}
body.theme-ayu-dark .tab {
background: #1f2430;
border-color: #3d424d;
color: #b3b1ad;
}
body.theme-ayu-dark .tab.active {
background: #0a0e14;
border-color: #ffaa33;
}
body.theme-ayu-dark .toolbar {
background: #0d1016;
border-bottom-color: #1f2430;
}
body.theme-ayu-dark .toolbar button {
color: #b3b1ad;
}
body.theme-ayu-dark .toolbar button:hover {
background: #1f2430;
border-color: #3d424d;
}
body.theme-ayu-dark .editor-textarea {
background: #0a0e14;
color: #b3b1ad;
}
body.theme-ayu-dark .pane:last-child {
background: #0a0e14;
}
body.theme-ayu-dark .preview-content {
color: #b3b1ad;
}
body.theme-ayu-dark .preview-content h1,
body.theme-ayu-dark .preview-content h2 {
border-bottom-color: #1f2430;
color: #ffaa33;
}
body.theme-ayu-dark .preview-content code {
background-color: #0d1016;
}
body.theme-ayu-dark .preview-content pre {
background-color: #0d1016;
}
body.theme-ayu-dark .preview-content a {
color: #39bae6;
}
body.theme-ayu-dark .status-bar {
background: #0d1016;
border-top-color: #1f2430;
color: #b3b1ad;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Ayu Light — id=ayu-light — category=light */
body.theme-ayu-light {
background: #fafafa;
color: #5c6166;
}
body.theme-ayu-light .tab-bar {
background: #f3f4f5;
border-bottom-color: #e7e8e9;
}
body.theme-ayu-light .tab {
background: #e7e8e9;
border-color: #d9dadb;
color: #5c6166;
}
body.theme-ayu-light .tab.active {
background: #fafafa;
border-color: #ff6a00;
}
body.theme-ayu-light .toolbar {
background: #f3f4f5;
border-bottom-color: #e7e8e9;
}
body.theme-ayu-light .toolbar button {
color: #5c6166;
}
body.theme-ayu-light .toolbar button:hover {
background: #e7e8e9;
border-color: #d9dadb;
}
body.theme-ayu-light .editor-textarea {
background: #fafafa;
color: #5c6166;
}
body.theme-ayu-light .pane:last-child {
background: #fafafa;
}
body.theme-ayu-light .preview-content {
color: #5c6166;
}
body.theme-ayu-light .preview-content h1,
body.theme-ayu-light .preview-content h2 {
border-bottom-color: #e7e8e9;
color: #ff6a00;
}
body.theme-ayu-light .preview-content code {
background-color: #f3f4f5;
}
body.theme-ayu-light .preview-content pre {
background-color: #f3f4f5;
}
body.theme-ayu-light .preview-content a {
color: #399ee6;
}
body.theme-ayu-light .status-bar {
background: #f3f4f5;
border-top-color: #e7e8e9;
color: #5c6166;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Ayu Mirage — id=ayu-mirage — category=dark */
body.theme-ayu-mirage {
background: #1f2430;
color: #cbccc6;
}
body.theme-ayu-mirage .tab-bar {
background: #232834;
border-bottom-color: #343d4b;
}
body.theme-ayu-mirage .tab {
background: #343d4b;
border-color: #465268;
color: #cbccc6;
}
body.theme-ayu-mirage .tab.active {
background: #1f2430;
border-color: #ffa759;
}
body.theme-ayu-mirage .toolbar {
background: #232834;
border-bottom-color: #343d4b;
}
body.theme-ayu-mirage .toolbar button {
color: #cbccc6;
}
body.theme-ayu-mirage .toolbar button:hover {
background: #343d4b;
border-color: #465268;
}
body.theme-ayu-mirage .editor-textarea {
background: #1f2430;
color: #cbccc6;
}
body.theme-ayu-mirage .pane:last-child {
background: #1f2430;
}
body.theme-ayu-mirage .preview-content {
color: #cbccc6;
}
body.theme-ayu-mirage .preview-content h1,
body.theme-ayu-mirage .preview-content h2 {
border-bottom-color: #343d4b;
color: #ffa759;
}
body.theme-ayu-mirage .preview-content code {
background-color: #232834;
}
body.theme-ayu-mirage .preview-content pre {
background-color: #232834;
}
body.theme-ayu-mirage .preview-content a {
color: #5ccfe6;
}
body.theme-ayu-mirage .status-bar {
background: #232834;
border-top-color: #343d4b;
color: #cbccc6;
}
+37
View File
@@ -0,0 +1,37 @@
/* Theme: Catppuccin Frappe — id=catppuccin-frappe — category=dark */
body.theme-catppuccin-frappe {
--bg: #303446;
--fg: #c6d0f5;
--accent: #8caaee;
--link: #f4b8e4;
--code-bg: #414559;
--border: #626880;
--muted: #838ba7;
}
body.theme-catppuccin-frappe .app-header,
body.theme-catppuccin-frappe .toolbar,
body.theme-catppuccin-frappe #editor,
body.theme-catppuccin-frappe #preview,
body.theme-catppuccin-frappe .preview-content,
body.theme-catppuccin-frappe .status-bar,
body.theme-catppuccin-frappe .tab-bar,
body.theme-catppuccin-frappe .tab,
body.theme-catppuccin-frappe .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-catppuccin-frappe a,
body.theme-catppuccin-frappe .preview-content a {
color: var(--link);
}
body.theme-catppuccin-frappe #preview code,
body.theme-catppuccin-frappe .preview-content code,
body.theme-catppuccin-frappe #preview pre,
body.theme-catppuccin-frappe .preview-content pre {
background: var(--code-bg);
}
+37
View File
@@ -0,0 +1,37 @@
/* Theme: Catppuccin Latte — id=catppuccin-latte — category=light */
body.theme-catppuccin-latte {
--bg: #eff1f5;
--fg: #4c4f69;
--accent: #1e66f5;
--link: #fe640b;
--code-bg: #e6e9ef;
--border: #bcc0cc;
--muted: #9ca0b0;
}
body.theme-catppuccin-latte .app-header,
body.theme-catppuccin-latte .toolbar,
body.theme-catppuccin-latte #editor,
body.theme-catppuccin-latte #preview,
body.theme-catppuccin-latte .preview-content,
body.theme-catppuccin-latte .status-bar,
body.theme-catppuccin-latte .tab-bar,
body.theme-catppuccin-latte .tab,
body.theme-catppuccin-latte .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-catppuccin-latte a,
body.theme-catppuccin-latte .preview-content a {
color: var(--link);
}
body.theme-catppuccin-latte #preview code,
body.theme-catppuccin-latte .preview-content code,
body.theme-catppuccin-latte #preview pre,
body.theme-catppuccin-latte .preview-content pre {
background: var(--code-bg);
}
@@ -0,0 +1,37 @@
/* Theme: Catppuccin Macchiato — id=catppuccin-macchiato — category=dark */
body.theme-catppuccin-macchiato {
--bg: #24273a;
--fg: #cad3f5;
--accent: #8aadf4;
--link: #f5bde6;
--code-bg: #363a4f;
--border: #5b6078;
--muted: #8087a2;
}
body.theme-catppuccin-macchiato .app-header,
body.theme-catppuccin-macchiato .toolbar,
body.theme-catppuccin-macchiato #editor,
body.theme-catppuccin-macchiato #preview,
body.theme-catppuccin-macchiato .preview-content,
body.theme-catppuccin-macchiato .status-bar,
body.theme-catppuccin-macchiato .tab-bar,
body.theme-catppuccin-macchiato .tab,
body.theme-catppuccin-macchiato .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-catppuccin-macchiato a,
body.theme-catppuccin-macchiato .preview-content a {
color: var(--link);
}
body.theme-catppuccin-macchiato #preview code,
body.theme-catppuccin-macchiato .preview-content code,
body.theme-catppuccin-macchiato #preview pre,
body.theme-catppuccin-macchiato .preview-content pre {
background: var(--code-bg);
}
+37
View File
@@ -0,0 +1,37 @@
/* Theme: Catppuccin Mocha — id=catppuccin-mocha — category=dark */
body.theme-catppuccin-mocha {
--bg: #1e1e2e;
--fg: #cdd6f4;
--accent: #89b4fa;
--link: #f5c2e7;
--code-bg: #313244;
--border: #585b70;
--muted: #7f849c;
}
body.theme-catppuccin-mocha .app-header,
body.theme-catppuccin-mocha .toolbar,
body.theme-catppuccin-mocha #editor,
body.theme-catppuccin-mocha #preview,
body.theme-catppuccin-mocha .preview-content,
body.theme-catppuccin-mocha .status-bar,
body.theme-catppuccin-mocha .tab-bar,
body.theme-catppuccin-mocha .tab,
body.theme-catppuccin-mocha .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-catppuccin-mocha a,
body.theme-catppuccin-mocha .preview-content a {
color: var(--link);
}
body.theme-catppuccin-mocha #preview code,
body.theme-catppuccin-mocha .preview-content code,
body.theme-catppuccin-mocha #preview pre,
body.theme-catppuccin-mocha .preview-content pre {
background: var(--code-bg);
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Cobalt2 — id=cobalt2 — category=dark */
body.theme-cobalt2 {
background: #193549;
color: #ffffff;
}
body.theme-cobalt2 .tab-bar {
background: #15232d;
border-bottom-color: #2a4a5d;
}
body.theme-cobalt2 .tab {
background: #0d3a58;
border-color: #2a4a5d;
color: #ffffff;
}
body.theme-cobalt2 .tab.active {
background: #193549;
border-color: #ffc600;
}
body.theme-cobalt2 .toolbar {
background: #15232d;
border-bottom-color: #2a4a5d;
}
body.theme-cobalt2 .toolbar button {
color: #ffffff;
}
body.theme-cobalt2 .toolbar button:hover {
background: #0d3a58;
border-color: #2a4a5d;
}
body.theme-cobalt2 .editor-textarea {
background: #193549;
color: #ffffff;
}
body.theme-cobalt2 .pane:last-child {
background: #193549;
}
body.theme-cobalt2 .preview-content {
color: #ffffff;
}
body.theme-cobalt2 .preview-content h1,
body.theme-cobalt2 .preview-content h2 {
border-bottom-color: #2a4a5d;
color: #ffc600;
}
body.theme-cobalt2 .preview-content code {
background-color: #0d3a58;
}
body.theme-cobalt2 .preview-content pre {
background-color: #0d3a58;
}
body.theme-cobalt2 .preview-content a {
color: #3ad900;
}
body.theme-cobalt2 .status-bar {
background: #15232d;
border-top-color: #2a4a5d;
color: #ffffff;
}
+144
View File
@@ -0,0 +1,144 @@
/* Theme: Concrete Dark — id=concrete-dark — category=dark */
body.theme-concrete-dark {
background: #0d0b09;
color: #e3e3e3;
}
body.theme-concrete-dark .tab-bar {
background: #1a1816;
border-bottom-color: #464646;
}
body.theme-concrete-dark .tab {
background: #464646;
border-color: #9a9696;
color: #e3e3e3;
}
body.theme-concrete-dark .tab.active {
background: #0d0b09;
border-color: #e5461f;
}
body.theme-concrete-dark .toolbar {
background: #1a1816;
border-bottom-color: #464646;
}
body.theme-concrete-dark .toolbar button {
color: #e3e3e3;
}
body.theme-concrete-dark .toolbar button:hover {
background: #464646;
border-color: #e5461f;
}
body.theme-concrete-dark .editor-textarea {
background: #0d0b09;
color: #e3e3e3;
}
body.theme-concrete-dark .pane:last-child {
background: #0d0b09;
}
body.theme-concrete-dark .preview-content {
color: #e3e3e3;
}
body.theme-concrete-dark .preview-content h1,
body.theme-concrete-dark .preview-content h2 {
border-bottom-color: #464646;
color: #e5461f;
}
body.theme-concrete-dark .preview-content code {
background-color: #464646;
}
body.theme-concrete-dark .preview-content pre {
background-color: #464646;
}
body.theme-concrete-dark .preview-content a {
color: #e5461f;
}
body.theme-concrete-dark .status-bar {
background: #1a1816;
border-top-color: #464646;
color: #9a9696;
}
body.theme-concrete-dark .toolbar {
background: linear-gradient(135deg, #0d0b09 0%, #1a1a1a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-dark .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-dark .tab-bar {
background: #0d0b09;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .tab {
background: #2a2a2a;
color: #e3e3e3;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-dark .tab.active {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .editor-pane {
background: #1a1a1a;
color: #e3e3e3;
}
body.theme-concrete-dark .status-bar {
background: #0d0b09;
color: #9a9696;
border-top: 1px solid #464646;
}
body.theme-concrete-dark .export-dialog-content,
body.theme-concrete-dark .batch-dialog-content {
background: #2a2a2a;
color: #e3e3e3;
}
body.theme-concrete-dark .export-dialog-header,
body.theme-concrete-dark .batch-dialog-header {
background: #1a1a1a;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-dark .export-dialog-footer,
body.theme-concrete-dark .batch-dialog-footer {
background: #1a1a1a;
}
body.theme-concrete-dark input,
body.theme-concrete-dark select,
body.theme-concrete-dark textarea {
background: #1a1a1a;
color: #e3e3e3;
border-color: #464646;
}
body.theme-concrete-dark input:focus,
body.theme-concrete-dark select:focus,
body.theme-concrete-dark textarea:focus {
border-color: #e5461f;
}
body.theme-concrete-dark .line-numbers {
background: #0d0b09;
color: #9a9696;
}
+128
View File
@@ -0,0 +1,128 @@
/* Theme: Concrete Light — id=concrete-light — category=light */
body.theme-concrete-light {
background: #fafafa;
color: #0d0b09;
}
body.theme-concrete-light .tab-bar {
background: #e3e3e3;
border-bottom-color: #9a9696;
}
body.theme-concrete-light .tab {
background: #e3e3e3;
border-color: #9a9696;
color: #0d0b09;
}
body.theme-concrete-light .tab.active {
background: #fafafa;
border-color: #e5461f;
}
body.theme-concrete-light .toolbar {
background: #e3e3e3;
border-bottom-color: #9a9696;
}
body.theme-concrete-light .toolbar button {
color: #0d0b09;
}
body.theme-concrete-light .toolbar button:hover {
background: #9a9696;
border-color: #e5461f;
}
body.theme-concrete-light .editor-textarea {
background: #fafafa;
color: #0d0b09;
}
body.theme-concrete-light .pane:last-child {
background: #fafafa;
}
body.theme-concrete-light .preview-content {
color: #0d0b09;
}
body.theme-concrete-light .preview-content h1,
body.theme-concrete-light .preview-content h2 {
border-bottom-color: #9a9696;
color: #e5461f;
}
body.theme-concrete-light .preview-content code {
background-color: #e3e3e3;
}
body.theme-concrete-light .preview-content pre {
background-color: #e3e3e3;
}
body.theme-concrete-light .preview-content a {
color: #e5461f;
}
body.theme-concrete-light .status-bar {
background: #e3e3e3;
border-top-color: #9a9696;
color: #464646;
}
body.theme-concrete-light .toolbar {
background: #ffffff;
border-bottom: 3px solid #e5461f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
body.theme-concrete-light .toolbar button:hover {
background: #e5461f;
color: #ffffff;
}
body.theme-concrete-light .tab-bar {
background: #ffffff;
border-bottom: 2px solid #e3e3e3;
}
body.theme-concrete-light .tab {
background: #f5f5f5;
color: #464646;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-light .tab.active {
background: #ffffff;
border-bottom: 2px solid #e5461f;
color: #e5461f;
}
body.theme-concrete-light .editor-pane {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .status-bar {
background: #ffffff;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-light .export-dialog-content,
body.theme-concrete-light .batch-dialog-content {
background: #ffffff;
color: #464646;
}
body.theme-concrete-light .export-dialog-header,
body.theme-concrete-light .batch-dialog-header {
background: #f5f5f5;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-light .line-numbers {
background: #f5f5f5;
color: #9a9696;
}
+139
View File
@@ -0,0 +1,139 @@
/* Theme: Concrete Warm — id=concrete-warm — category=dark */
body.theme-concrete-warm {
background: #464646;
color: #e3e3e3;
}
body.theme-concrete-warm .tab-bar {
background: #3a3836;
border-bottom-color: #9a9696;
}
body.theme-concrete-warm .tab {
background: #9a9696;
border-color: #e3e3e3;
color: #0d0b09;
}
body.theme-concrete-warm .tab.active {
background: #464646;
border-color: #e5461f;
}
body.theme-concrete-warm .toolbar {
background: #3a3836;
border-bottom-color: #9a9696;
}
body.theme-concrete-warm .toolbar button {
color: #e3e3e3;
}
body.theme-concrete-warm .toolbar button:hover {
background: #9a9696;
border-color: #e5461f;
}
body.theme-concrete-warm .editor-textarea {
background: #464646;
color: #e3e3e3;
}
body.theme-concrete-warm .pane:last-child {
background: #464646;
}
body.theme-concrete-warm .preview-content {
color: #e3e3e3;
}
body.theme-concrete-warm .preview-content h1,
body.theme-concrete-warm .preview-content h2 {
border-bottom-color: #9a9696;
color: #e5461f;
}
body.theme-concrete-warm .preview-content code {
background-color: #3a3836;
}
body.theme-concrete-warm .preview-content pre {
background-color: #3a3836;
}
body.theme-concrete-warm .preview-content a {
color: #e5461f;
}
body.theme-concrete-warm .status-bar {
background: #3a3836;
border-top-color: #9a9696;
color: #e3e3e3;
}
body.theme-concrete-warm .toolbar {
background: linear-gradient(135deg, #464646 0%, #5a5a5a 100%);
border-bottom: 3px solid #e5461f;
}
body.theme-concrete-warm .toolbar button:hover {
background: #e5461f;
}
body.theme-concrete-warm .tab-bar {
background: #464646;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .tab {
background: #6a6a6a;
color: #faf8f5;
border-radius: 4px 4px 0 0;
}
body.theme-concrete-warm .tab.active {
background: #faf8f5;
color: #e5461f;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm .editor-pane {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .status-bar {
background: #f0ebe4;
color: #9a9696;
border-top: 1px solid #e3e3e3;
}
body.theme-concrete-warm .export-dialog-content,
body.theme-concrete-warm .batch-dialog-content {
background: #faf8f5;
color: #464646;
}
body.theme-concrete-warm .export-dialog-header,
body.theme-concrete-warm .batch-dialog-header {
background: #f0ebe4;
border-bottom: 2px solid #e5461f;
}
body.theme-concrete-warm h1,
body.theme-concrete-warm h2,
body.theme-concrete-warm h3,
body.theme-concrete-warm h4,
body.theme-concrete-warm h5,
body.theme-concrete-warm h6 {
color: #e5461f;
}
body.theme-concrete-warm a {
color: #e5461f;
}
body.theme-concrete-warm .line-numbers {
background: #f0ebe4;
color: #9a9696;
}
+170
View File
@@ -0,0 +1,170 @@
/* Theme: Dark — id=dark — category=dark */
body.theme-dark .pane-resizer {
background: linear-gradient(to right, #333, #444, #333);
}
body.theme-dark .pane-resizer:hover {
background: #61afef;
}
body.theme-dark .pdf-viewer-toolbar {
background: linear-gradient(180deg, #2d2d2d 0%, #252525 100%) !important;
border-bottom: 1px solid #1a1a1a !important;
}
body.theme-dark .pdf-viewer-toolbar button {
background: #3d3d3d !important;
border: 1px solid #4d4d4d !important;
color: #e0e0e0 !important;
}
body.theme-dark .pdf-viewer-toolbar button:hover {
background: #4d4d4d !important;
border-color: #5d5d5d !important;
}
body.theme-dark .pdf-viewer {
background: #1a1a1a !important;
}
body.theme-dark .pdf-viewer-container {
background: #1a1a1a !important;
}
body.theme-dark #pdf-page-input {
background: #3d3d3d !important;
border: 1px solid #4d4d4d !important;
color: #e0e0e0 !important;
}
body.theme-dark .pdf-toolbar-separator {
background: #3d3d3d !important;
}
body.theme-dark .preview-content,
body.theme-dark [id^='preview-'] {
color: #e6e6e6;
background: transparent;
}
body.theme-dark .preview-content h1,
body.theme-dark .preview-content h2,
body.theme-dark .preview-content h3,
body.theme-dark .preview-content h4,
body.theme-dark .preview-content h5,
body.theme-dark .preview-content h6 {
color: #f0f0f0;
border-bottom-color: #444;
}
body.theme-dark .preview-content p,
body.theme-dark .preview-content li,
body.theme-dark .preview-content td {
color: #d4d4d4;
}
body.theme-dark .preview-content pre {
background: #1e1e1e;
border-color: #333;
color: #d4d4d4;
}
body.theme-dark .preview-content code {
background: rgba(255, 255, 255, 0.1);
border-color: #444;
color: #e06c75;
}
body.theme-dark .preview-content blockquote {
background: rgba(255, 255, 255, 0.05);
border-left-color: #555;
color: #aaa;
}
body.theme-dark .preview-content a {
color: #61afef;
}
body.theme-dark .preview-content table {
border-color: #444;
}
body.theme-dark .preview-content table th {
background: #2d2d2d;
color: #e6e6e6;
border-color: #444;
}
body.theme-dark .preview-content table td {
border-color: #444;
}
body.theme-dark .preview-content table tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
body.theme-dark .preview-content hr {
border-color: #444;
background: #444;
}
body.theme-dark .export-dialog-content,
body.theme-dark .batch-dialog-content,
body.theme-dark .pdf-editor-content {
background: #2d2d2d !important;
border-color: #444 !important;
color: #e6e6e6 !important;
}
body.theme-dark .export-dialog-header,
body.theme-dark .batch-dialog-header {
background: #1a1a1a !important;
border-bottom-color: #333 !important;
}
body.theme-dark .export-dialog-header h3,
body.theme-dark .batch-dialog-header h3 {
color: #fff !important;
}
body.theme-dark .export-dialog-body,
body.theme-dark .batch-dialog-body {
background: transparent;
}
body.theme-dark .export-section label,
body.theme-dark .batch-section label {
color: #ccc;
}
body.theme-dark .export-section select,
body.theme-dark .export-section input,
body.theme-dark .batch-section select,
body.theme-dark .batch-section input {
background: #2d2d2d;
border-color: #444;
color: #e6e6e6;
}
body.theme-dark .export-dialog-footer,
body.theme-dark .batch-dialog-footer {
background: #1a1a1a;
border-top-color: #333;
}
body.theme-dark .app-header {
background: #1f2937;
border-bottom-color: #374151;
}
body.theme-dark .app-title {
color: #f3f4f6;
}
body.theme-dark .app-version {
color: #9ca3af;
}
body.theme-dark .app-logo {
filter: none;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Dracula — id=dracula — category=dark */
body.theme-dracula {
background: #282a36;
color: #f8f8f2;
}
body.theme-dracula .tab-bar {
background: #343746;
border-bottom-color: #44475a;
}
body.theme-dracula .tab {
background: #44475a;
border-color: #6272a4;
color: #f8f8f2;
}
body.theme-dracula .tab.active {
background: #282a36;
border-color: #bd93f9;
}
body.theme-dracula .toolbar {
background: #343746;
border-bottom-color: #44475a;
}
body.theme-dracula .toolbar button {
color: #f8f8f2;
}
body.theme-dracula .toolbar button:hover {
background: #44475a;
border-color: #6272a4;
}
body.theme-dracula .editor-textarea {
background: #282a36;
color: #f8f8f2;
}
body.theme-dracula .pane:last-child {
background: #282a36;
}
body.theme-dracula .preview-content {
color: #f8f8f2;
}
body.theme-dracula .preview-content h1,
body.theme-dracula .preview-content h2 {
border-bottom-color: #44475a;
color: #bd93f9;
}
body.theme-dracula .preview-content code {
background-color: #44475a;
}
body.theme-dracula .preview-content pre {
background-color: #44475a;
}
body.theme-dracula .preview-content a {
color: #8be9fd;
}
body.theme-dracula .status-bar {
background: #343746;
border-top-color: #44475a;
color: #f8f8f2;
}
+126
View File
@@ -0,0 +1,126 @@
/* Theme: GitHub Light — id=github — category=light */
body.theme-github {
background: #fff;
color: #24292e;
}
body.theme-github .tab-bar {
background: #fafbfc;
border-bottom-color: #e1e4e8;
}
body.theme-github .tab {
background: #f6f8fa;
border-color: #e1e4e8;
color: #24292e;
}
body.theme-github .tab.active {
background: #fff;
border-color: #0366d6;
}
body.theme-github .tab-close {
color: #586069;
}
body.theme-github .tab-close:hover {
background: #e1e4e8;
color: #cb2431;
}
body.theme-github .new-tab-button {
border-color: #e1e4e8;
color: #586069;
}
body.theme-github .new-tab-button:hover {
background: #f6f8fa;
}
body.theme-github .toolbar {
background: #fafbfc;
border-bottom-color: #e1e4e8;
}
body.theme-github .toolbar button {
color: #586069;
}
body.theme-github .toolbar button:hover {
background: #f6f8fa;
border-color: #e1e4e8;
}
body.theme-github #editor {
background: #fff;
color: #24292e;
}
body.theme-github #preview,
body.theme-github .preview-content {
color: #24292e;
}
body.theme-github #preview h1,
body.theme-github #preview h2,
body.theme-github .preview-content h1,
body.theme-github .preview-content h2 {
border-bottom-color: #eaecef;
}
body.theme-github #preview code,
body.theme-github .preview-content code {
background-color: rgba(27, 31, 35, 0.05);
}
body.theme-github #preview pre,
body.theme-github .preview-content pre {
background-color: #f6f8fa;
}
body.theme-github #preview blockquote,
body.theme-github .preview-content blockquote {
color: #6a737d;
border-left-color: #dfe2e5;
}
body.theme-github #preview a,
body.theme-github .preview-content a {
color: #0366d6;
}
body.theme-github .status-bar {
background: #fafbfc;
border-top-color: #e1e4e8;
color: #586069;
}
body.theme-github .line-numbers {
background: #fafbfc;
border-right-color: #e1e4e8;
color: #586069;
}
body.theme-github .export-mode-toggle {
border-color: #e1e4e8;
}
body.theme-github .export-help {
color: #586069;
}
body.theme-github .basic-options {
background: #f6f8fa;
border-color: #e1e4e8;
}
body.theme-github .enhanced-stats {
color: #586069;
border-color: #e1e4e8;
}
body.theme-github .mermaid {
background: #f6f8fa;
border-color: #e1e4e8;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Gruvbox Dark — id=gruvbox-dark — category=dark */
body.theme-gruvbox-dark {
background: #282828;
color: #ebdbb2;
}
body.theme-gruvbox-dark .tab-bar {
background: #3c3836;
border-bottom-color: #504945;
}
body.theme-gruvbox-dark .tab {
background: #504945;
border-color: #665c54;
color: #ebdbb2;
}
body.theme-gruvbox-dark .tab.active {
background: #282828;
border-color: #fabd2f;
}
body.theme-gruvbox-dark .toolbar {
background: #3c3836;
border-bottom-color: #504945;
}
body.theme-gruvbox-dark .toolbar button {
color: #ebdbb2;
}
body.theme-gruvbox-dark .toolbar button:hover {
background: #504945;
border-color: #665c54;
}
body.theme-gruvbox-dark .editor-textarea {
background: #282828;
color: #ebdbb2;
}
body.theme-gruvbox-dark .pane:last-child {
background: #282828;
}
body.theme-gruvbox-dark .preview-content {
color: #ebdbb2;
}
body.theme-gruvbox-dark .preview-content h1,
body.theme-gruvbox-dark .preview-content h2 {
border-bottom-color: #504945;
color: #fabd2f;
}
body.theme-gruvbox-dark .preview-content code {
background-color: #3c3836;
}
body.theme-gruvbox-dark .preview-content pre {
background-color: #3c3836;
}
body.theme-gruvbox-dark .preview-content a {
color: #83a598;
}
body.theme-gruvbox-dark .status-bar {
background: #3c3836;
border-top-color: #504945;
color: #ebdbb2;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Gruvbox Light — id=gruvbox-light — category=light */
body.theme-gruvbox-light {
background: #fbf1c7;
color: #3c3836;
}
body.theme-gruvbox-light .tab-bar {
background: #ebdbb2;
border-bottom-color: #d5c4a1;
}
body.theme-gruvbox-light .tab {
background: #d5c4a1;
border-color: #bdae93;
color: #3c3836;
}
body.theme-gruvbox-light .tab.active {
background: #fbf1c7;
border-color: #b57614;
}
body.theme-gruvbox-light .toolbar {
background: #ebdbb2;
border-bottom-color: #d5c4a1;
}
body.theme-gruvbox-light .toolbar button {
color: #3c3836;
}
body.theme-gruvbox-light .toolbar button:hover {
background: #d5c4a1;
border-color: #bdae93;
}
body.theme-gruvbox-light .editor-textarea {
background: #fbf1c7;
color: #3c3836;
}
body.theme-gruvbox-light .pane:last-child {
background: #fbf1c7;
}
body.theme-gruvbox-light .preview-content {
color: #3c3836;
}
body.theme-gruvbox-light .preview-content h1,
body.theme-gruvbox-light .preview-content h2 {
border-bottom-color: #d5c4a1;
color: #b57614;
}
body.theme-gruvbox-light .preview-content code {
background-color: #ebdbb2;
}
body.theme-gruvbox-light .preview-content pre {
background-color: #ebdbb2;
}
body.theme-gruvbox-light .preview-content a {
color: #076678;
}
body.theme-gruvbox-light .status-bar {
background: #ebdbb2;
border-top-color: #d5c4a1;
color: #3c3836;
}
+85
View File
@@ -0,0 +1,85 @@
/* Theme: Light — id=light — category=light */
body.theme-light .pdf-viewer-toolbar {
background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%) !important;
border-bottom: 1px solid #d0d0d0 !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
body.theme-light .pdf-viewer-toolbar button {
background: #fff !important;
border: 1px solid #ccc !important;
color: #333 !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
body.theme-light .pdf-viewer-toolbar button:hover {
background: #e8e8e8 !important;
border-color: #bbb !important;
}
body.theme-light .pdf-editor-btn {
background: linear-gradient(135deg, #4a90d9, #357abd) !important;
border: 1px solid #357abd !important;
}
body.theme-light .pdf-editor-btn:hover {
background: linear-gradient(135deg, #357abd, #2868a0) !important;
}
body.theme-light #pdf-page-info,
body.theme-light #pdf-zoom-level {
color: #444 !important;
font-weight: 500;
}
body.theme-light #pdf-page-input {
background: #fff !important;
border: 1px solid #ccc !important;
color: #333 !important;
}
body.theme-light .pdf-toolbar-separator {
background: #d0d0d0 !important;
}
body.theme-light .pdf-filename {
color: #555 !important;
}
body.theme-light .pdf-viewer-container {
background: #e8e8e8;
}
body.theme-light .pdf-viewer-toolbar {
background: #f5f5f5;
border-bottom-color: #ddd;
}
body.theme-light .pdf-viewer-toolbar button {
background: #fff;
border-color: #ddd;
color: #333;
}
body.theme-light .pdf-viewer-toolbar button:hover {
background: #e8e8e8;
}
body.theme-light #pdf-page-info,
body.theme-light #pdf-zoom-level {
color: #333;
}
body.theme-light #pdf-page-input {
background: #fff;
border-color: #ddd;
color: #333;
}
body.theme-light .pdf-filename {
color: #666;
}
body.theme-light .pdf-viewer {
background: #d0d0d0;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Material — id=material — category=dark */
body.theme-material {
background: #263238;
color: #eeffff;
}
body.theme-material .tab-bar {
background: #2e3c43;
border-bottom-color: #37474f;
}
body.theme-material .tab {
background: #37474f;
border-color: #546e7a;
color: #eeffff;
}
body.theme-material .tab.active {
background: #263238;
border-color: #82aaff;
}
body.theme-material .toolbar {
background: #2e3c43;
border-bottom-color: #37474f;
}
body.theme-material .toolbar button {
color: #eeffff;
}
body.theme-material .toolbar button:hover {
background: #37474f;
border-color: #546e7a;
}
body.theme-material .editor-textarea {
background: #263238;
color: #eeffff;
}
body.theme-material .pane:last-child {
background: #263238;
}
body.theme-material .preview-content {
color: #eeffff;
}
body.theme-material .preview-content h1,
body.theme-material .preview-content h2 {
border-bottom-color: #37474f;
color: #82aaff;
}
body.theme-material .preview-content code {
background-color: #2e3c43;
}
body.theme-material .preview-content pre {
background-color: #2e3c43;
}
body.theme-material .preview-content a {
color: #80cbc4;
}
body.theme-material .status-bar {
background: #2e3c43;
border-top-color: #37474f;
color: #eeffff;
}
+135
View File
@@ -0,0 +1,135 @@
/* Theme: Monokai — id=monokai — category=dark */
body.theme-monokai {
background: #272822;
color: #f8f8f2;
}
body.theme-monokai .tab-bar {
background: #3e3d32;
border-bottom-color: #75715e;
}
body.theme-monokai .tab {
background: #49483e;
border-color: #75715e;
color: #f8f8f2;
}
body.theme-monokai .tab.active {
background: #272822;
border-color: #a6e22e;
}
body.theme-monokai .tab-close {
color: #f8f8f2;
}
body.theme-monokai .tab-close:hover {
background: #75715e;
color: #f92672;
}
body.theme-monokai .new-tab-button {
border-color: #75715e;
color: #f8f8f2;
}
body.theme-monokai .new-tab-button:hover {
background: #49483e;
}
body.theme-monokai .toolbar {
background: #3e3d32;
border-bottom-color: #75715e;
}
body.theme-monokai .toolbar button {
color: #f8f8f2;
}
body.theme-monokai .toolbar button:hover {
background: #49483e;
border-color: #75715e;
}
body.theme-monokai #editor {
background: #272822;
color: #f8f8f2;
}
body.theme-monokai .editor-textarea {
background: #272822;
color: #f8f8f2;
}
body.theme-monokai #preview-pane {
background: #272822;
}
body.theme-monokai #preview,
body.theme-monokai .preview-content {
color: #f8f8f2;
}
body.theme-monokai #preview h1,
body.theme-monokai #preview h2,
body.theme-monokai .preview-content h1,
body.theme-monokai .preview-content h2 {
border-bottom-color: #75715e;
}
body.theme-monokai #preview code,
body.theme-monokai .preview-content code {
background-color: #3e3d32;
}
body.theme-monokai #preview pre,
body.theme-monokai .preview-content pre {
background-color: #3e3d32;
}
body.theme-monokai #preview blockquote,
body.theme-monokai .preview-content blockquote {
color: #75715e;
border-left-color: #75715e;
}
body.theme-monokai #preview a,
body.theme-monokai .preview-content a {
color: #66d9ef;
}
body.theme-monokai .status-bar {
background: #3e3d32;
border-top-color: #75715e;
color: #75715e;
}
body.theme-monokai .line-numbers {
background: #2f2f2f;
border-right-color: #555;
color: #75715e;
}
body.theme-monokai .export-mode-toggle {
border-color: #49483e;
}
body.theme-monokai .export-help {
color: #75715e;
}
body.theme-monokai .basic-options {
background: #2f2f2f;
border-color: #49483e;
}
body.theme-monokai .enhanced-stats {
color: #75715e;
border-color: #49483e;
}
body.theme-monokai .mermaid {
background: #2f2f2f;
border-color: #49483e;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Nord — id=nord — category=dark */
body.theme-nord {
background: #2e3440;
color: #d8dee9;
}
body.theme-nord .tab-bar {
background: #3b4252;
border-bottom-color: #4c566a;
}
body.theme-nord .tab {
background: #434c5e;
border-color: #4c566a;
color: #d8dee9;
}
body.theme-nord .tab.active {
background: #2e3440;
border-color: #88c0d0;
}
body.theme-nord .toolbar {
background: #3b4252;
border-bottom-color: #4c566a;
}
body.theme-nord .toolbar button {
color: #d8dee9;
}
body.theme-nord .toolbar button:hover {
background: #434c5e;
border-color: #4c566a;
}
body.theme-nord .editor-textarea {
background: #2e3440;
color: #d8dee9;
}
body.theme-nord .pane:last-child {
background: #2e3440;
}
body.theme-nord .preview-content {
color: #d8dee9;
}
body.theme-nord .preview-content h1,
body.theme-nord .preview-content h2 {
border-bottom-color: #4c566a;
color: #88c0d0;
}
body.theme-nord .preview-content code {
background-color: #3b4252;
}
body.theme-nord .preview-content pre {
background-color: #3b4252;
}
body.theme-nord .preview-content a {
color: #81a1c1;
}
body.theme-nord .status-bar {
background: #3b4252;
border-top-color: #4c566a;
color: #d8dee9;
}
+72
View File
@@ -0,0 +1,72 @@
/* Theme: Oceanic Next — id=oceanic-next — category=dark */
body.theme-oceanic-next {
background: #1b2b34;
color: #cdd3de;
}
body.theme-oceanic-next .tab-bar {
background: #20353f;
border-bottom-color: #343d46;
}
body.theme-oceanic-next .tab {
background: #343d46;
border-color: #4f5b66;
color: #cdd3de;
}
body.theme-oceanic-next .tab.active {
background: #1b2b34;
border-color: #6699cc;
}
body.theme-oceanic-next .toolbar {
background: #20353f;
border-bottom-color: #343d46;
}
body.theme-oceanic-next .toolbar button {
color: #cdd3de;
}
body.theme-oceanic-next .toolbar button:hover {
background: #343d46;
border-color: #4f5b66;
}
body.theme-oceanic-next .editor-textarea {
background: #1b2b34;
color: #cdd3de;
}
body.theme-oceanic-next .pane:last-child {
background: #1b2b34;
}
body.theme-oceanic-next .preview-content {
color: #cdd3de;
}
body.theme-oceanic-next .preview-content h1,
body.theme-oceanic-next .preview-content h2 {
border-bottom-color: #343d46;
color: #6699cc;
}
body.theme-oceanic-next .preview-content code {
background-color: #20353f;
}
body.theme-oceanic-next .preview-content pre {
background-color: #20353f;
}
body.theme-oceanic-next .preview-content a {
color: #5fb3b3;
}
body.theme-oceanic-next .status-bar {
background: #20353f;
border-top-color: #343d46;
color: #cdd3de;
}
+37
View File
@@ -0,0 +1,37 @@
/* Theme: One Light — id=one-light — category=light */
body.theme-one-light {
--bg: #fafafa;
--fg: #383a42;
--accent: #4078f2;
--link: #e45649;
--code-bg: #f0f0f0;
--border: #d3d3d3;
--muted: #a0a1a7;
}
body.theme-one-light .app-header,
body.theme-one-light .toolbar,
body.theme-one-light #editor,
body.theme-one-light #preview,
body.theme-one-light .preview-content,
body.theme-one-light .status-bar,
body.theme-one-light .tab-bar,
body.theme-one-light .tab,
body.theme-one-light .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-one-light a,
body.theme-one-light .preview-content a {
color: var(--link);
}
body.theme-one-light #preview code,
body.theme-one-light .preview-content code,
body.theme-one-light #preview pre,
body.theme-one-light .preview-content pre {
background: var(--code-bg);
}
+88
View File
@@ -0,0 +1,88 @@
/* Theme: One Dark — id=onedark — category=dark */
body.theme-onedark,
body.theme-one-dark {
background: #282c34;
color: #abb2bf;
}
body.theme-onedark .tab-bar,
body.theme-one-dark .tab-bar {
background: #21252b;
border-bottom-color: #181a1f;
}
body.theme-onedark .tab,
body.theme-one-dark .tab {
background: #2c313a;
border-color: #3e4451;
color: #abb2bf;
}
body.theme-onedark .tab.active,
body.theme-one-dark .tab.active {
background: #282c34;
border-color: #61afef;
}
body.theme-onedark .toolbar,
body.theme-one-dark .toolbar {
background: #21252b;
border-bottom-color: #181a1f;
}
body.theme-onedark .toolbar button,
body.theme-one-dark .toolbar button {
color: #abb2bf;
}
body.theme-onedark .toolbar button:hover,
body.theme-one-dark .toolbar button:hover {
background: #2c313a;
border-color: #3e4451;
}
body.theme-onedark .editor-textarea,
body.theme-one-dark .editor-textarea {
background: #282c34;
color: #abb2bf;
}
body.theme-onedark .pane:last-child,
body.theme-one-dark .pane:last-child {
background: #282c34;
}
body.theme-onedark .preview-content,
body.theme-one-dark .preview-content {
color: #abb2bf;
}
body.theme-onedark .preview-content h1,
body.theme-onedark .preview-content h2,
body.theme-one-dark .preview-content h1,
body.theme-one-dark .preview-content h2 {
border-bottom-color: #3e4451;
color: #61afef;
}
body.theme-onedark .preview-content code,
body.theme-one-dark .preview-content code {
background-color: #21252b;
}
body.theme-onedark .preview-content pre,
body.theme-one-dark .preview-content pre {
background-color: #21252b;
}
body.theme-onedark .preview-content a,
body.theme-one-dark .preview-content a {
color: #56b6c2;
}
body.theme-onedark .status-bar,
body.theme-one-dark .status-bar {
background: #21252b;
border-top-color: #181a1f;
color: #abb2bf;
}
+40
View File
@@ -0,0 +1,40 @@
/* Theme: Outrun — id=outrun — category=dark */
/* Note: brief palette omits --border. Chosen --border: #1a0626 as a deeper purple
that sits between the very dark --bg #0d0221 and the mid --muted #3a1453,
giving structural separation without colliding with the magenta accent. */
body.theme-outrun {
--bg: #0d0221;
--fg: #f0eff0;
--accent: #ff0080;
--link: #00f9ff;
--code-bg: #240b36;
--border: #1a0626;
--muted: #3a1453;
}
body.theme-outrun .app-header,
body.theme-outrun .toolbar,
body.theme-outrun #editor,
body.theme-outrun #preview,
body.theme-outrun .preview-content,
body.theme-outrun .status-bar,
body.theme-outrun .tab-bar,
body.theme-outrun .tab,
body.theme-outrun .modal-content {
background: var(--bg);
color: var(--fg);
border-color: var(--border);
}
body.theme-outrun a,
body.theme-outrun .preview-content a {
color: var(--link);
}
body.theme-outrun #preview code,
body.theme-outrun .preview-content code,
body.theme-outrun #preview pre,
body.theme-outrun .preview-content pre {
background: var(--code-bg);
}

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