mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
111d36bf3d | ||
|
|
a13a87c499 | ||
|
|
bd620284ab | ||
|
|
a6407b5839 | ||
|
|
1a357bbd6a | ||
|
|
dfce7fd3ac | ||
|
|
fc017e0101 | ||
|
|
bf12996a40 | ||
|
|
f133efe025 | ||
|
|
7bb062a464 | ||
|
|
7673f5533d | ||
|
|
d4a2c21196 | ||
|
|
d90be20a72 | ||
|
|
e967af77a7 | ||
|
|
41ddaa17b3 | ||
|
|
48c7420c7a | ||
|
|
dadfe52870 | ||
|
|
8fb61130f3 | ||
|
|
ded17d0b6b | ||
|
|
22bc4dcf56 | ||
|
|
4123455863 | ||
|
|
7beb3effcf | ||
|
|
9412049f56 | ||
|
|
98979a2cd2 | ||
|
|
4228ab78d8 | ||
|
|
0bb1091c47 | ||
|
|
855dfcd9f5 | ||
|
|
d5efdfa8d9 | ||
|
|
3ec9ff0130 | ||
|
|
e826c0e624 | ||
|
|
47315add80 | ||
|
|
0d3df32140 | ||
|
|
b36d0919a3 | ||
|
|
72ca2cb46f | ||
|
|
1a25669967 | ||
|
|
cb0b2eac3d | ||
|
|
b70dce9574 | ||
|
|
3bd958c064 | ||
|
|
33fa14c11a | ||
|
|
de7ebecad4 | ||
|
|
23046967c3 | ||
|
|
e87e301b0f | ||
|
|
075e407f3f | ||
|
|
b81119026e | ||
|
|
81e8561403 | ||
|
|
ca5983e9ff | ||
|
|
86d4c0d23a | ||
|
|
5f846d1e5f | ||
|
|
9557c0af4d | ||
|
|
d31ed86fab | ||
|
|
e0945b427d | ||
|
|
ac31162d3b | ||
|
|
8c708b7abb | ||
|
|
58aa4da00f | ||
|
|
b28d11035b | ||
|
|
bf0ed6857d | ||
|
|
aee30a2a9c | ||
|
|
b0dcf57daf | ||
|
|
a6dc54cedc | ||
|
|
59ee9c609e | ||
|
|
0331a08207 | ||
|
|
216e0ecf51 | ||
|
|
4a36b7db76 | ||
|
|
7869be0ff9 | ||
|
|
2ca543de35 | ||
|
|
02c9250c0d | ||
|
|
ab50183dc0 | ||
|
|
8c162c1714 | ||
|
|
38bc2b2bc5 | ||
|
|
1d68fc132f | ||
|
|
fecd23d35e | ||
|
|
f089f60d70 | ||
|
|
9751bbc6df | ||
|
|
57fee91e32 | ||
|
|
fba848ef16 | ||
|
|
bb99a5c5ee | ||
|
|
f71aa2b097 | ||
|
|
5385e159ed | ||
|
|
3fbb47000e | ||
|
|
66def72f3e | ||
|
|
c1c9ab4a4c | ||
|
|
0bc50c7d54 | ||
|
|
ceeda453be | ||
|
|
311304f365 | ||
|
|
8392e33f5b | ||
|
|
a2b4a856f3 | ||
|
|
eaad62d02e | ||
|
|
1e7e332c24 | ||
|
|
b8c772269c | ||
|
|
87902c18ce | ||
|
|
43c26c6521 | ||
|
|
b1b72f9a60 | ||
|
|
c3fe72bcae | ||
|
|
2aa72738f4 | ||
|
|
0cab0b08c7 | ||
|
|
1b85bc47a4 | ||
|
|
bd86748c47 | ||
|
|
6d08c138d8 | ||
|
|
7397e7f618 | ||
|
|
da97369b44 | ||
|
|
3f856bc857 | ||
|
|
cd0050d988 | ||
|
|
dfb364bcd8 | ||
|
|
cd2980277b |
@@ -68,9 +68,27 @@ jobs:
|
||||
|
||||
- name: Bundle MarkItDown (optional, best effort)
|
||||
# Windows runners ship python + venv; non-fatal on failure — the
|
||||
# package then omits the bundled binary by design
|
||||
run: npm run bundle:markitdown
|
||||
# package then omits the bundled binary by design. We capture the
|
||||
# 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
|
||||
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
|
||||
run: npm test
|
||||
|
||||
@@ -52,11 +52,12 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
|
||||
### Advanced Features
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
@@ -71,9 +72,10 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- [Node.js](https://nodejs.org/) (v16 or later)
|
||||
- [Pandoc](https://pandoc.org/installing.html) (required for export functionality)
|
||||
- Optional: [MarkItDown](https://github.com/microsoft/markitdown) (`pip install "markitdown[all]"`) for any-file → Markdown import
|
||||
- [Node.js](https://nodejs.org/) (v16 or later) — only for development builds
|
||||
- [Pandoc](https://pandoc.org/installing.html) — **bundled** inside the app, no install needed
|
||||
- [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
|
||||
```bash
|
||||
@@ -125,37 +127,67 @@ npm run build:linux
|
||||
| ASCII Art Generator | Ctrl+Shift+A |
|
||||
| Next Comment | F8 |
|
||||
| Add Comment at Cursor | Ctrl+Alt+M |
|
||||
| Flow Chart: Undo | Ctrl+Z |
|
||||
| Flow Chart: Redo | Ctrl+Shift+Z |
|
||||
| Flow Chart: Delete selected | Delete |
|
||||
|
||||
## Themes
|
||||
|
||||
### Light Themes
|
||||
- Atom One Light (Default)
|
||||
- GitHub Light
|
||||
- Light
|
||||
- Solarized Light
|
||||
- Gruvbox Light
|
||||
- Ayu Light
|
||||
- Sepia
|
||||
- Paper
|
||||
- Rose Pine Dawn
|
||||
- Concrete Light
|
||||
37 built-in editor themes, registered in `src/main/ThemeRegistry.bootstrap.js`.
|
||||
|
||||
### Dark Themes
|
||||
- Dark
|
||||
- One Dark
|
||||
- Dracula
|
||||
- Nord
|
||||
- Monokai
|
||||
- Material
|
||||
- Gruvbox Dark
|
||||
- Tokyo Night
|
||||
- Palenight
|
||||
- Ayu Dark
|
||||
- Ayu Mirage
|
||||
- Oceanic Next
|
||||
- Cobalt2
|
||||
- Concrete Dark
|
||||
- Concrete Warm
|
||||
### Light (14)
|
||||
|
||||
| Theme | Id |
|
||||
|---|---|
|
||||
| Atom One Light (Default) | `atomonelight` |
|
||||
| GitHub Light | `github` |
|
||||
| Light | `light` |
|
||||
| Solarized Light | `solarized` |
|
||||
| Gruvbox Light | `gruvbox-light` |
|
||||
| Ayu Light | `ayu-light` |
|
||||
| Sepia | `sepia` |
|
||||
| Paper | `paper` |
|
||||
| Rose Pine Dawn | `rosepine-dawn` |
|
||||
| Concrete Light | `concrete-light` |
|
||||
| Catppuccin Latte | `catppuccin-latte` |
|
||||
| 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
|
||||
|
||||
@@ -225,4 +257,4 @@ Amit Haridas (amit.wh@gmail.com)
|
||||
|
||||
## Version
|
||||
|
||||
v4.7.1
|
||||
v4.13.0
|
||||
|
||||
+283
@@ -1,8 +1,251 @@
|
||||
# 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)
|
||||
|
||||
### New: Export Themes (Word + PDF)
|
||||
|
||||
- Theme picker in the export dialog (basic and advanced mode) for PDF and DOCX:
|
||||
**Default (Pandoc), Modern, Classic, Sepia, Minimal, Elegant**
|
||||
- PDF: LaTeX header recolors/reformats headings + links (xcolor/titlesec,
|
||||
@@ -12,10 +255,12 @@
|
||||
Default instead of failing the export
|
||||
|
||||
### Branding
|
||||
|
||||
- New M↓ brand identity: app icons, favicons, tray icon, welcome mark,
|
||||
README wordmark (vector kit in assets/markdown-converter-assets/)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Windows**: pdfjs text/image extraction failed on Windows —
|
||||
standardFontDataUrl is now a proper file:// URL (raw backslash paths
|
||||
failed pdfjs's trailing-slash URL validation)
|
||||
@@ -33,6 +278,7 @@
|
||||
## Version 4.7.0 (2026-09-05)
|
||||
|
||||
### Bundling & Legal Compliance
|
||||
|
||||
- **MarkItDown is now bundled**: `npm run bundle:markitdown` freezes Microsoft's
|
||||
markitdown (MIT) + embedded Python runtime into a single ~75MB per-platform
|
||||
binary (`bin/<platform>/markitdown`) via PyInstaller (ML extras excluded);
|
||||
@@ -56,6 +302,7 @@
|
||||
## Version 4.6.1 (2026-09-05)
|
||||
|
||||
### New Features
|
||||
|
||||
- **MarkItDown import** — "File → Import with MarkItDown (Any Format)…" embeds
|
||||
Microsoft's [markitdown](https://github.com/microsoft/markitdown) (MIT) as an
|
||||
any-file → Markdown path: PDF, DOCX, PPTX, XLSX, Outlook .msg/.eml, EPUB,
|
||||
@@ -74,6 +321,7 @@
|
||||
without a trailing `/v1`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- File → Open PDF crashed the PDF editor (null operation matched no section; now
|
||||
defaults to Merge)
|
||||
- Backlinks panel required the wrong module path (failed at registration)
|
||||
@@ -89,6 +337,7 @@
|
||||
### New Features
|
||||
|
||||
#### AI Assistant Plugin (multi-provider)
|
||||
|
||||
- Chat sidebar panel with rolling conversation history and insert-reply-into-document
|
||||
- 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
|
||||
@@ -96,30 +345,36 @@
|
||||
- Answers the writing-studio `ai:analyze` contract, finally enabling the Proofread panel
|
||||
|
||||
#### Collaboration Plugin (inline comments)
|
||||
|
||||
- Anchor-based comments stored in `.comments/` sidecar files (never exported, never committed)
|
||||
- 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
|
||||
|
||||
#### Local Knowledge Base (wiki-links + backlinks)
|
||||
|
||||
- `[[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
|
||||
- Backlinks sidebar panel scans the folder (bounded BFS) for documents linking to the current one
|
||||
|
||||
#### 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
|
||||
- Restore prompt on launch with per-tab restore, clean-fresh option, and 2MB content budget
|
||||
|
||||
#### Document Version History
|
||||
|
||||
- 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"
|
||||
- Per-document pruning (20 versions), path-hash storage, id-validated reads
|
||||
|
||||
#### Editor
|
||||
|
||||
- Vim keybindings (View → Vim Mode, persisted, live toggle via CodeMirror Compartment)
|
||||
- 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)
|
||||
|
||||
#### 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)
|
||||
- **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
|
||||
@@ -128,6 +383,7 @@
|
||||
- Writing heatmap (GitHub-style 30-day grid) in the writing-studio Goals panel
|
||||
|
||||
#### Platform
|
||||
|
||||
- Quick Note global scratchpad (Ctrl+Alt+Q, works when unfocused; appends to `notes/quick-notes.md`)
|
||||
- Deep link protocol `markdownconverter://open?path=…`
|
||||
- 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})`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- `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)
|
||||
- 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
|
||||
|
||||
### Security
|
||||
|
||||
- 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
|
||||
- PlantUML local rendering removes the diagram-text exfiltration path when a local CLI exists (CVE-MC-007 follow-up)
|
||||
|
||||
### Tests
|
||||
|
||||
- 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
|
||||
|
||||
@@ -155,12 +414,14 @@
|
||||
## Version 4.0.0 (2026-03-04)
|
||||
|
||||
### Major Changes
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
||||
### New Features
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### New Export/Import Formats
|
||||
|
||||
- Reveal.js slides (.html)
|
||||
- Beamer slides (.pdf)
|
||||
- Confluence/Jira wiki markup (.txt)
|
||||
@@ -180,12 +442,14 @@
|
||||
- Developer formats: JSON, YAML, XML, TOML
|
||||
|
||||
### Security
|
||||
|
||||
- Content Security Policy (CSP) meta tag
|
||||
- File size validation (50MB limit)
|
||||
- Error message sanitization (stripped file paths)
|
||||
- Conversion rate limiting (2-second debounce)
|
||||
|
||||
### Dependencies Updated
|
||||
|
||||
- marked: 16.x to 17.x (with marked-highlight extension)
|
||||
- pdfjs-dist: 3.x to 5.x (new worker model)
|
||||
- html2pdf.js: 0.10 to 0.14
|
||||
@@ -193,10 +457,12 @@
|
||||
- dompurify, docx, and others updated to latest
|
||||
|
||||
### Testing
|
||||
|
||||
- 80 tests across 7 test suites
|
||||
- New tests for sidebar manager, command palette, print preview, markdown extensions, and utility functions
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Editor is now CodeMirror 6 (replaces textarea)
|
||||
- marked API changed to use marked.use() instead of marked.setOptions()
|
||||
- pdfjs-dist upgraded to v5 with new worker model
|
||||
@@ -208,6 +474,7 @@
|
||||
### 🎨 UI/UX Improvements
|
||||
|
||||
#### Subtle & Small Preview Popout Button
|
||||
|
||||
- Redesigned popout button with minimalist aesthetic
|
||||
- Removed border for cleaner appearance
|
||||
- Reduced size: 11px font, 2px×6px padding (previously 14px font, 4px×8px padding)
|
||||
@@ -216,6 +483,7 @@
|
||||
- **File**: `src/styles.css:195-211`
|
||||
|
||||
#### Simplified Table Headers in Preview
|
||||
|
||||
- Removed gradient background from table headers in modern theme
|
||||
- Changed from `var(--primary-gradient)` (purple gradient) to simple light gray (#f0f0f0)
|
||||
- Updated text color to dark (#333333) for better readability
|
||||
@@ -225,9 +493,11 @@
|
||||
### 📥 Enhanced Import Capabilities
|
||||
|
||||
#### Comprehensive Format-to-Markdown Conversion
|
||||
|
||||
Dramatically expanded the "Import Document" feature to support 30+ file formats:
|
||||
|
||||
**Supported Formats:**
|
||||
|
||||
- **Documents**: DOCX, ODT, RTF, HTML, HTM, TEX, EPUB, PDF, TXT
|
||||
- **Presentations**: PPTX, ODP
|
||||
- **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
|
||||
|
||||
**Format-Specific Optimizations:**
|
||||
|
||||
- PDF text extraction with XeLaTeX engine
|
||||
- CSV/TSV automatic table conversion
|
||||
- JSON structure handling
|
||||
@@ -249,6 +520,7 @@ Dramatically expanded the "Import Document" feature to support 30+ file formats:
|
||||
### 🎨 Exhaustive ASCII Art Generator
|
||||
|
||||
#### 5 New Text Banner Styles
|
||||
|
||||
Complete alphabet (A-Z) and numbers (0-9) support for all styles:
|
||||
|
||||
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`
|
||||
|
||||
#### 19 Professional ASCII Templates
|
||||
|
||||
Organized into 4 categories with expanded options:
|
||||
|
||||
**Arrows & Flow (4 templates):**
|
||||
|
||||
- Arrow Right - Horizontal flow indicators
|
||||
- Arrow Down - Vertical flow indicators
|
||||
- Decision - Binary decision diagrams
|
||||
- Process Flow - Multi-step process visualization
|
||||
|
||||
**Diagrams & Charts (6 templates):**
|
||||
|
||||
- Flowchart - Advanced flowchart with decision branches and loops
|
||||
- Sequence - Sequence diagrams for User-System-Database interactions
|
||||
- Network - Server-client network topology
|
||||
@@ -277,12 +552,14 @@ Organized into 4 categories with expanded options:
|
||||
- Table Simple - Basic table template with borders
|
||||
|
||||
**Boxes & Containers (4 templates):**
|
||||
|
||||
- Header - Section header with decorative borders
|
||||
- Note Box - Important notes with rounded corners (┏━━┓)
|
||||
- Warning Box - Warning messages with bold borders (╔═══╗)
|
||||
- Info Box - Information boxes with subtle styling (╭───╮)
|
||||
|
||||
**Decorative Elements (6 templates):**
|
||||
|
||||
- Divider - Horizontal section separator (═══)
|
||||
- Separator Fancy - Elegant rounded divider
|
||||
- Brackets - Japanese-style brackets 【 】
|
||||
@@ -291,6 +568,7 @@ Organized into 4 categories with expanded options:
|
||||
- Progress Bar - Visual progress indicators
|
||||
|
||||
**Features:**
|
||||
|
||||
- All ASCII art automatically wrapped in code blocks for proper rendering
|
||||
- Preserved formatting in markdown preview and all export formats
|
||||
- Categorized template selection interface
|
||||
@@ -320,6 +598,7 @@ Organized into 4 categories with expanded options:
|
||||
## Version 2.0.0 (Previous Release)
|
||||
|
||||
### Major Features
|
||||
|
||||
- Export Profiles - Save and reuse export configurations
|
||||
- Mermaid.js diagram support
|
||||
- Command Palette (Ctrl+Shift+P)
|
||||
@@ -334,6 +613,7 @@ Organized into 4 categories with expanded options:
|
||||
- 22 beautiful themes
|
||||
|
||||
### Core Capabilities
|
||||
|
||||
- Cross-platform markdown editor with live preview
|
||||
- Universal document conversion (30+ formats)
|
||||
- PDF Editor (merge, split, compress, rotate, watermark, encrypt)
|
||||
@@ -347,13 +627,16 @@ Organized into 4 categories with expanded options:
|
||||
## Installation & Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Pandoc** - Required for document conversion
|
||||
- **Optional**: LibreOffice, ImageMagick, FFmpeg for universal converter
|
||||
|
||||
### Download
|
||||
|
||||
Get the latest release from: https://github.com/amitwh/pan-converter/releases
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
- Windows (x64)
|
||||
- Linux (AppImage, .deb, .snap)
|
||||
- macOS (planned)
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
@@ -36,6 +36,16 @@ module.exports = {
|
||||
copyright: 'Copyright (C) 2024-2025 ConcreteInfo',
|
||||
directories: { output: 'dist' },
|
||||
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: [
|
||||
'src/**/*',
|
||||
'assets/**/*',
|
||||
@@ -115,6 +125,21 @@ module.exports = {
|
||||
category: 'Utility',
|
||||
maintainer: 'ConcreteInfo <amit.wh@gmail.com>',
|
||||
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: {
|
||||
depends: ['imagemagick', 'libreoffice-common'],
|
||||
|
||||
@@ -62,6 +62,12 @@ module.exports = [
|
||||
HTMLInputElement: 'readonly',
|
||||
HTMLTextAreaElement: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
CSS: 'readonly',
|
||||
Element: 'readonly',
|
||||
// Modern Web APIs available in Node 20+ and Chromium
|
||||
AbortController: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
TextEncoder: 'readonly',
|
||||
// Electron
|
||||
electronAPI: 'readonly',
|
||||
// Libraries
|
||||
|
||||
Generated
+1910
-2542
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"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",
|
||||
"main": "src/main.js",
|
||||
"desktopName": "MarkdownConverter",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
@@ -25,6 +29,7 @@
|
||||
"dist:all": "electron-builder --config electron-builder.config.js -mwl",
|
||||
"download-tools": "node scripts/download-tools.js",
|
||||
"bundle:markitdown": "node scripts/bundle-markitdown.js",
|
||||
"build:bundle": "node scripts/build-flowchart-bundle.js",
|
||||
"generate-icons": "node scripts/generate-icons.js"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -77,7 +82,9 @@
|
||||
"docx": "^9.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-store": "^10.1.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"figlet": "^1.8.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -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 };
|
||||
@@ -29,9 +29,14 @@ const FIRACODE_VERSION = '6.2';
|
||||
const KNOWN_SHA256 = {
|
||||
'linux:pandoc': '7d124235998ecd3cdd9a463b1e5f6691a178b6461824c29a36170a0882f05597',
|
||||
'win32:pandoc.exe': 'e83f8354c0f507222b5684797b9c5ae766f03889785995d14aac27816ec456ba',
|
||||
// Fill these from a trusted machine after the first download of each
|
||||
// platform (the script prints the computed hash):
|
||||
// 'darwin:pandoc': '…',
|
||||
// darwin:pandoc is unpinned because this branch has no macOS host to
|
||||
// compute the hash from. The verifyArtifact() function below prints a
|
||||
// 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)
|
||||
'fonts:FiraCode-Regular.ttf': '5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117',
|
||||
'fonts:FiraCode-Bold.ttf': '41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be',
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ASCII Art Generator - MarkdownConverter</title>
|
||||
<link rel="stylesheet" href="../fonts.css" />
|
||||
<link rel="stylesheet" href="fonts.css" />
|
||||
<style>
|
||||
:root {
|
||||
--ci-dark-gray: #464646;
|
||||
@@ -262,14 +262,15 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Style</label>
|
||||
<select id="font-style" class="form-select">
|
||||
<option value="standard">Standard</option>
|
||||
<option value="banner">Banner</option>
|
||||
<option value="block">Block</option>
|
||||
<option value="bubble">Bubble</option>
|
||||
<option value="digital">Digital</option>
|
||||
</select>
|
||||
<label class="form-label">Font</label>
|
||||
<input
|
||||
type="text"
|
||||
id="font-search"
|
||||
class="form-input"
|
||||
placeholder="Search fonts…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<select id="font-picker" class="form-select" size="8"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +351,10 @@
|
||||
|
||||
<div class="section">
|
||||
<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 id="preview" class="preview-content"></div>
|
||||
</div>
|
||||
@@ -358,394 +363,11 @@
|
||||
|
||||
<div class="footer">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ASCII Art Generator Logic
|
||||
const FONTS = {
|
||||
standard: {
|
||||
height: 5,
|
||||
chars: {
|
||||
A: [' /\\ ', ' / \\ ', '/----\\', '| |', '| |'],
|
||||
B: ['|----\\', '| |', '|----/', '| \\', '|----/'],
|
||||
C: ['/----\\', '| ', '| ', '| ', '\\----/'],
|
||||
D: ['|----\\', '| |', '| |', '| |', '|----/'],
|
||||
E: ['|----', '| ', '|--- ', '| ', '|----'],
|
||||
F: ['|----', '| ', '|--- ', '| ', '| '],
|
||||
G: ['/----\\', '| ', '| |--\\', '| |', '\\----/'],
|
||||
H: ['| |', '| |', '|----/', '| |', '| |'],
|
||||
I: ['|---|', ' | ', ' | ', ' | ', '|---|'],
|
||||
J: [' |', ' |', ' |', '| |', '\\---/'],
|
||||
K: ['| /', '| / ', '|-- ', '| \\ ', '| \\'],
|
||||
L: ['| ', '| ', '| ', '| ', '|----'],
|
||||
M: ['|\\ /|', '| \\/ |', '| |', '| |', '| |'],
|
||||
N: ['|\\ |', '| \\ |', '| \\ |', '| \\|', '| |'],
|
||||
O: ['/----\\', '| |', '| |', '| |', '\\----/'],
|
||||
P: ['|----\\', '| |', '|----/', '| ', '| '],
|
||||
Q: ['/----\\', '| |', '| \\ |', '| \\|', '\\----\\'],
|
||||
R: ['|----\\', '| |', '|----/', '| \\ ', '| \\ '],
|
||||
S: ['/----\\', '| ', '\\----\\', ' |', '\\----/'],
|
||||
T: ['-----', ' | ', ' | ', ' | ', ' | '],
|
||||
U: ['| |', '| |', '| |', '| |', '\\----/'],
|
||||
V: ['| |', '| |', ' \\ / ', ' \\/ ', ' '],
|
||||
W: ['| |', '| |', '| |', '| /\\ |', '|/ \\|'],
|
||||
X: ['\\ /', ' \\ / ', ' \\/ ', ' /\\ ', ' / \\ '],
|
||||
Y: ['\\ /', ' \\ / ', ' | ', ' | ', ' | '],
|
||||
Z: ['-----', ' / ', ' / ', ' / ', '-----'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
0: ['/---\\', '| |', '| / |', '|/ |', '\\---/'],
|
||||
1: [' /| ', ' / | ', ' | ', ' | ', ' ----'],
|
||||
2: ['/---\\', ' |', ' ---/', '/ ', '-----'],
|
||||
3: ['----\\', ' |', ' ---/', ' |', '----/'],
|
||||
4: ['| |', '| |', '-----', ' |', ' |'],
|
||||
5: ['-----', '| ', '----\\', ' |', '----/'],
|
||||
6: ['/----', '| ', '|---\\', '| |', '\\---/'],
|
||||
7: ['-----', ' / ', ' / ', ' / ', '/ '],
|
||||
8: ['/---\\', '| |', ' --- ', '| |', '\\---/'],
|
||||
9: ['/---\\', '| |', '\\----', ' |', '----/'],
|
||||
},
|
||||
},
|
||||
banner: {
|
||||
height: 7,
|
||||
chars: {
|
||||
A: [
|
||||
' ##### ',
|
||||
' ## ##',
|
||||
'## ##',
|
||||
'#########',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
],
|
||||
B: [
|
||||
'######## ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'######## ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'######## ',
|
||||
],
|
||||
C: [' ###### ', '## ##', '## ', '## ', '## ', '## ##', ' ###### '],
|
||||
D: [
|
||||
'######## ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'######## ',
|
||||
],
|
||||
E: ['########', '## ', '## ', '###### ', '## ', '## ', '########'],
|
||||
F: ['########', '## ', '## ', '###### ', '## ', '## ', '## '],
|
||||
G: [' ###### ', '## ##', '## ', '## ####', '## ##', '## ##', ' ###### '],
|
||||
H: [
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'#########',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
],
|
||||
I: ['####', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '####'],
|
||||
J: [' ##', ' ##', ' ##', ' ##', '## ##', '## ##', ' ###### '],
|
||||
K: ['## ##', '## ## ', '## ## ', '##### ', '## ## ', '## ## ', '## ##'],
|
||||
L: ['## ', '## ', '## ', '## ', '## ', '## ', '########'],
|
||||
M: [
|
||||
'## ##',
|
||||
'### ###',
|
||||
'#### ####',
|
||||
'## ### ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
],
|
||||
N: ['## ##', '### ##', '#### ##', '## ## ##', '## ####', '## ###', '## ##'],
|
||||
O: [
|
||||
' ####### ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
' ####### ',
|
||||
],
|
||||
P: [
|
||||
'######## ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'######## ',
|
||||
'## ',
|
||||
'## ',
|
||||
'## ',
|
||||
],
|
||||
Q: [
|
||||
' ####### ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ## ##',
|
||||
'## ## ',
|
||||
' ##### ##',
|
||||
],
|
||||
R: [
|
||||
'######## ',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'######## ',
|
||||
'## ## ',
|
||||
'## ## ',
|
||||
'## ##',
|
||||
],
|
||||
S: [' ###### ', '## ##', '## ', ' ###### ', ' ##', '## ##', ' ###### '],
|
||||
T: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||
U: [
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
' ####### ',
|
||||
],
|
||||
V: [
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
'## ##',
|
||||
' ## ## ',
|
||||
' ## ## ',
|
||||
' ### ',
|
||||
],
|
||||
W: [
|
||||
'## ##',
|
||||
'## ## ##',
|
||||
'## ## ##',
|
||||
'## ## ##',
|
||||
'## ## ##',
|
||||
'## ## ##',
|
||||
' ### ### ',
|
||||
],
|
||||
X: [
|
||||
'## ##',
|
||||
' ## ## ',
|
||||
' ## ## ',
|
||||
' ### ',
|
||||
' ## ## ',
|
||||
' ## ## ',
|
||||
'## ##',
|
||||
],
|
||||
Y: ['## ##', ' ## ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||
Z: ['########', ' ## ', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
|
||||
0: [' ###### ', '## ##', '## ##', '## ##', '## ##', '## ##', ' ###### '],
|
||||
1: [' ## ', ' #### ', ' ## ', ' ## ', ' ## ', ' ## ', ' ###### '],
|
||||
2: [' ###### ', '## ##', ' ## ', ' ## ', ' ## ', ' ## ', '########'],
|
||||
3: [' ###### ', '## ##', ' ## ', ' #### ', ' ## ', '## ##', ' ###### '],
|
||||
4: [' ## ', ' ### ', ' # ## ', ' # ## ', '########', ' ## ', ' ## '],
|
||||
5: ['########', '## ', '####### ', ' ##', ' ##', '## ##', ' ###### '],
|
||||
6: [' ###### ', '## ', '####### ', '## ##', '## ##', '## ##', ' ###### '],
|
||||
7: ['########', '## ## ', ' ## ', ' ## ', ' ## ', ' ## ', ' ## '],
|
||||
8: [' ###### ', '## ##', '## ##', ' ###### ', '## ##', '## ##', ' ###### '],
|
||||
9: [' ###### ', '## ##', '## ##', ' #######', ' ##', '## ##', ' ###### '],
|
||||
},
|
||||
},
|
||||
block: {
|
||||
height: 6,
|
||||
chars: {
|
||||
A: ['█████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
||||
C: ['█████╗ ', '██╔══██╗', '██║ ', '██║ ', '╚█████╔╝', ' ╚════╝ '],
|
||||
D: ['██████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '██████╔╝', '╚═════╝ '],
|
||||
E: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '███████╗', '╚══════╝'],
|
||||
F: ['███████╗', '██╔════╝', '█████╗ ', '██╔══╝ ', '██║ ', '╚═╝ '],
|
||||
G: ['█████╗ ', '██╔══██╗', '██║ ███', '██║ ██', '╚█████╔╝', ' ╚════╝ '],
|
||||
H: ['██╗ ██╗', '██║ ██║', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
||||
I: ['██╗', '██║', '██║', '██║', '██║', '╚═╝'],
|
||||
J: [' ██╗', ' ██║', ' ██║', '██ ██║', '╚████╔╝', ' ╚═══╝ '],
|
||||
K: ['██╗ ██╗', '██║ ██╔╝', '█████╔╝ ', '██╔═██╗ ', '██║ ██╗', '╚═╝ ╚═╝'],
|
||||
L: ['██╗ ', '██║ ', '██║ ', '██║ ', '███████╗', '╚══════╝'],
|
||||
M: [
|
||||
'███╗ ███╗',
|
||||
'████╗ ████║',
|
||||
'██╔████╔██║',
|
||||
'██║╚██╔╝██║',
|
||||
'██║ ╚═╝ ██║',
|
||||
'╚═╝ ╚═╝',
|
||||
],
|
||||
N: ['███╗ ██╗', '████╗ ██║', '██╔██╗ ██║', '██║╚██╗██║', '██║ ╚████║', '╚═╝ ╚═══╝'],
|
||||
O: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||
P: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔═══╝ ', '██║ ', '╚═╝ '],
|
||||
Q: ['█████╗ ', '██╔══██╗', '██║ ██║', '██║ ██║', '╚██████╗', ' ╚═══██╝'],
|
||||
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
||||
S: ['█████╗ ', '██╔══╝ ', '█████╗ ', '╚══██║ ', '█████║ ', '╚════╝ '],
|
||||
T: ['████████╗', '╚══██╔══╝', ' ██║ ', ' ██║ ', ' ██║ ', ' ╚═╝ '],
|
||||
U: ['██╗ ██╗', '██║ ██║', '██║ ██║', '██║ ██║', '╚█████╔╝', ' ╚════╝ '],
|
||||
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
||||
W: ['██╗ ██╗', '██║ ██║', '██║ █╗ ██║', '██║███╗██║', '╚███╔███╔╝', ' ╚══╝╚══╝ '],
|
||||
X: ['██╗ ██╗', '╚██╗██╔╝', ' ╚███╔╝ ', ' ██╔██╗ ', '██╔╝ ██╗', '╚═╝ ╚═╝'],
|
||||
Y: ['██╗ ██╗', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚██╔╝ ', ' ██║ ', ' ╚═╝ '],
|
||||
Z: ['███████╗', '╚════██║', ' ███╔═╝', ' ██╔══╝ ', '███████╗', '╚══════╝'],
|
||||
' ': [' ', ' ', ' ', ' ', ' ', ' '],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TEMPLATES = {
|
||||
'arrow-right':
|
||||
' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
|
||||
'arrow-down':
|
||||
' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
|
||||
decision:
|
||||
' ╱╲\n ╱ ╲\n ╱ ? ╲\n ╱ ╲\n ╱────────╲\n ╱ ╲\n YES NO\n │ │\n ▼ ▼',
|
||||
process:
|
||||
'┌─────┐ ┌─────┐ ┌─────┐\n│ 1 │──▶│ 2 │──▶│ 3 │\n└─────┘ └─────┘ └─────┘',
|
||||
flowchart:
|
||||
'┌─────────────┐\n│ START │\n└──────┬──────┘\n │\n ▼\n┌─────────────┐\n│ Process A │\n└──────┬──────┘\n │\n ▼\n ╱────────╲\n ╱ Decision ╲\n ╲ ? ╱\n ╲────────╱\n │ │\n YES NO\n │ │\n ▼ ▼\n┌──────┐ ┌──────┐\n│ B │ │ C │\n└──────┘ └──────┘',
|
||||
sequence:
|
||||
' User System Database\n │ │ │\n │ Request │ │\n ├──────────►│ │\n │ │ Query │\n │ ├──────────►│\n │ │ │\n │ │ Result │\n │ │◄──────────┤\n │ Response │ │\n │◄──────────┤ │\n │ │ │',
|
||||
network:
|
||||
' ┌─────────┐\n │ Server │\n └────┬────┘\n │\n ┌─────────┼─────────┐\n │ │ │\n┌────┴────┐ ┌──┴──┐ ┌────┴────┐\n│ Client1 │ │ DB │ │ Client2 │\n└─────────┘ └─────┘ └─────────┘',
|
||||
hierarchy:
|
||||
' ┌─────────┐\n │ CEO │\n └────┬────┘\n ┌─────────┼─────────┐\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ VP1 │ │ VP2 │ │ VP3 │\n └───┬───┘ └───┬───┘ └───┬───┘\n │ │ │\n ┌───┴───┐ ┌───┴───┐ ┌───┴───┐\n │ Team1 │ │ Team2 │ │ Team3 │\n └───────┘ └───────┘ └───────┘',
|
||||
header:
|
||||
'╔════════════════════════════════════╗\n║ SECTION TITLE ║\n╚════════════════════════════════════╝',
|
||||
note: '┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n┃ NOTE: ┃\n┃ This is an important note ┃\n┃ that requires attention! ┃\n┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛',
|
||||
warning:
|
||||
'╔════════════════════════════════════╗\n║ ⚠️ WARNING ║\n║ ║\n║ Critical information here! ║\n╚════════════════════════════════════╝',
|
||||
info: '╭────────────────────────────────────╮\n│ ℹ️ INFO │\n│ │\n│ Helpful information here. │\n╰────────────────────────────────────╯',
|
||||
divider: '════════════════════════════════════════',
|
||||
separator:
|
||||
'╭──────────────────────────────────────╮\n│ │\n╰──────────────────────────────────────╯',
|
||||
banner:
|
||||
'★══════════════════════════════════════★\n║ YOUR TITLE HERE ║\n★══════════════════════════════════════★',
|
||||
checklist:
|
||||
'☐ Task 1 - Not completed\n☑ Task 2 - Completed \n☐ Task 3 - Not completed\n☐ Task 4 - Not completed',
|
||||
};
|
||||
|
||||
const BOX_STYLES = {
|
||||
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
|
||||
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
||||
rounded: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
|
||||
bold: { tl: '┏', tr: '┓', bl: '┗', br: '┛', h: '━', v: '┃' },
|
||||
ascii: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
|
||||
};
|
||||
|
||||
let currentMode = 'text';
|
||||
let currentTemplate = null;
|
||||
|
||||
// Mode switching
|
||||
document.querySelectorAll('.mode-tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.mode-tab').forEach((t) => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentMode = tab.dataset.mode;
|
||||
|
||||
document.querySelectorAll('.mode-section').forEach((s) => s.classList.remove('active'));
|
||||
document.getElementById(currentMode + '-mode').classList.add('active');
|
||||
|
||||
generatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Template selection
|
||||
document.querySelectorAll('.template-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.template-btn').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentTemplate = btn.dataset.template;
|
||||
generatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Generate text banner
|
||||
function generateTextBanner(text, style) {
|
||||
const font = FONTS[style] || FONTS.standard;
|
||||
const lines = Array(font.height).fill('');
|
||||
|
||||
for (const char of text.toUpperCase()) {
|
||||
const charArt = font.chars[char] || font.chars[' '];
|
||||
if (charArt) {
|
||||
for (let i = 0; i < font.height; i++) {
|
||||
lines[i] += charArt[i] + ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Generate box
|
||||
function generateBox(text, style, padding) {
|
||||
const box = BOX_STYLES[style] || BOX_STYLES.single;
|
||||
const lines = text.split('\n');
|
||||
const maxLen = Math.max(...lines.map((l) => l.length)) + padding * 2;
|
||||
|
||||
let result = box.tl + box.h.repeat(maxLen + 2) + box.tr + '\n';
|
||||
|
||||
// Add padding lines at top
|
||||
for (let i = 0; i < Math.floor(padding / 2); i++) {
|
||||
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
|
||||
}
|
||||
|
||||
// Add text lines
|
||||
for (const line of lines) {
|
||||
const paddedLine = ' '.repeat(padding) + line.padEnd(maxLen - padding) + ' ';
|
||||
result += box.v + ' ' + paddedLine + box.v + '\n';
|
||||
}
|
||||
|
||||
// Add padding lines at bottom
|
||||
for (let i = 0; i < Math.floor(padding / 2); i++) {
|
||||
result += box.v + ' '.repeat(maxLen + 2) + box.v + '\n';
|
||||
}
|
||||
|
||||
result += box.bl + box.h.repeat(maxLen + 2) + box.br;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate preview
|
||||
function generatePreview() {
|
||||
let result = '';
|
||||
|
||||
if (currentMode === 'text') {
|
||||
const text = document.getElementById('text-input').value || 'HELLO';
|
||||
const style = document.getElementById('font-style').value;
|
||||
result = generateTextBanner(text, style);
|
||||
} else if (currentMode === 'box') {
|
||||
const text = document.getElementById('box-text').value || 'Your text here';
|
||||
const style = document.getElementById('box-style').value;
|
||||
const padding = parseInt(document.getElementById('box-padding').value) || 2;
|
||||
result = generateBox(text, style, padding);
|
||||
} else if (currentMode === 'templates') {
|
||||
result = currentTemplate ? TEMPLATES[currentTemplate] : 'Select a template...';
|
||||
}
|
||||
|
||||
document.getElementById('preview').textContent = result;
|
||||
}
|
||||
|
||||
// Event listeners for live preview
|
||||
document.getElementById('text-input').addEventListener('input', generatePreview);
|
||||
document.getElementById('font-style').addEventListener('change', generatePreview);
|
||||
document.getElementById('box-text').addEventListener('input', generatePreview);
|
||||
document.getElementById('box-style').addEventListener('change', generatePreview);
|
||||
document.getElementById('box-padding').addEventListener('input', generatePreview);
|
||||
|
||||
document.getElementById('btn-generate').addEventListener('click', generatePreview);
|
||||
|
||||
document.getElementById('btn-insert').addEventListener('click', () => {
|
||||
const content = document.getElementById('preview').textContent;
|
||||
if (content && window.electronAPI) {
|
||||
// Wrap in code block for markdown
|
||||
const wrapped = '```\n' + content + '\n```';
|
||||
window.electronAPI.send('insert-generated-content', wrapped);
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial preview
|
||||
generatePreview();
|
||||
</script>
|
||||
<script src="renderer/ascii-controller.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -121,6 +121,7 @@ function createEditor(parentElement, options = {}) {
|
||||
showLineNumbers = true,
|
||||
vimMode = false,
|
||||
getTabExpansion = null,
|
||||
smartPasteFetcher = null,
|
||||
} = options;
|
||||
|
||||
const extensions = [
|
||||
@@ -155,6 +156,13 @@ function createEditor(parentElement, options = {}) {
|
||||
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) {
|
||||
extensions.push(lineNumbers());
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 · 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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
|
||||
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
@@ -35,6 +35,111 @@
|
||||
<link rel="stylesheet" href="styles-welcome.css" />
|
||||
<link rel="stylesheet" href="styles-zen.css" />
|
||||
<link rel="stylesheet" href="../node_modules/highlight.js/styles/default.css" />
|
||||
<!-- 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 -->
|
||||
<link rel="stylesheet" href="../assets/katex/katex.min.css" />
|
||||
</head>
|
||||
@@ -809,224 +914,6 @@
|
||||
</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">×</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 -->
|
||||
<div
|
||||
id="universal-converter-dialog"
|
||||
@@ -2558,6 +2445,70 @@
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Search + Q&A: workspace content search / doc-aware Q&A -->
|
||||
<button
|
||||
class="sidebar-icon"
|
||||
data-panel="search"
|
||||
title="Search & 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 class="sidebar-panel" id="sidebar-panel">
|
||||
<div class="sidebar-panel-header">
|
||||
@@ -2831,6 +2782,20 @@
|
||||
<span class="status-separator">|</span>
|
||||
<span class="status-item" id="char-count">Chars: 0</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-separator">|</span>
|
||||
<span class="status-item" id="encoding">UTF-8</span>
|
||||
|
||||
+710
-178
File diff suppressed because it is too large
Load Diff
@@ -255,8 +255,203 @@ async function complete(request, options = {}) {
|
||||
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 = {
|
||||
complete,
|
||||
completeStream,
|
||||
resolveSettings,
|
||||
AiProviderError,
|
||||
PROVIDER_DEFAULTS,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -152,9 +152,7 @@ function buildLatexThemeHeader(themeId) {
|
||||
// exotic minimal TeX install degrades by omission (pandoc still succeeds)
|
||||
lines.push('\\usepackage{titlesec}');
|
||||
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
|
||||
const rule = p.sectionRule && p.ruleColor
|
||||
? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
|
||||
: '';
|
||||
const rule = p.sectionRule && p.ruleColor ? '{\\color{mcthemerule}\\titlerule[0.6pt]}' : '';
|
||||
lines.push(
|
||||
`\\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
|
||||
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';
|
||||
}
|
||||
@@ -193,7 +193,16 @@ function applyDocxTheme(docxPath, themeId, io = {}) {
|
||||
if (!stylesFile) return false;
|
||||
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) {
|
||||
// Match this style's <w:style …w:styleId="X">…</w:style> block (they
|
||||
// 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)
|
||||
if (d.bodyFont) {
|
||||
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
|
||||
xml = xml.replace(
|
||||
/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/,
|
||||
`$1${patch}`
|
||||
);
|
||||
xml = xml.replace(/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/, `$1${patch}`);
|
||||
}
|
||||
|
||||
zip.file('word/styles.xml', xml);
|
||||
|
||||
@@ -160,7 +160,9 @@ async function convertToMarkdown(inputPath, options = {}) {
|
||||
// Prefer the LAST informative stderr line: markitdown ends its
|
||||
// output with the actionable install hint ("pip install
|
||||
// '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 =
|
||||
stderrLines[stderrLines.length - 1] ||
|
||||
safe(error.message).split('\n')[0] ||
|
||||
|
||||
@@ -445,10 +445,7 @@ async function loadPdfjs() {
|
||||
// Windows path (C:\...\standard_fonts\) fails that check and breaks
|
||||
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
|
||||
function getStandardFontDataUrl() {
|
||||
const dir = path.join(
|
||||
path.dirname(require.resolve('pdfjs-dist/package.json')),
|
||||
'standard_fonts'
|
||||
);
|
||||
const dir = path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts');
|
||||
const url = require('url').pathToFileURL(dir);
|
||||
return url.href.endsWith('/') ? url.href : url.href + '/';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
'—': '—',
|
||||
'–': '–',
|
||||
'…': '…',
|
||||
'©': '©',
|
||||
'®': '®',
|
||||
'™': '™',
|
||||
};
|
||||
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 <b> 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
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.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) done(null);
|
||||
|
||||
+134
-4
@@ -44,6 +44,17 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'ai-assistant:complete',
|
||||
'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-convert',
|
||||
'select-folder',
|
||||
@@ -98,6 +109,21 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
// ASCII generator (separate window)
|
||||
'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)
|
||||
'open-table-generator',
|
||||
|
||||
@@ -175,11 +201,40 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
// Quick Note scratchpad
|
||||
'quick-note:save',
|
||||
|
||||
// Quick-switcher (Cmd+P workspace file picker)
|
||||
'quick-switcher:list-files',
|
||||
'recent-files:get',
|
||||
|
||||
// Document version history
|
||||
'version-history:list',
|
||||
'version-history:read',
|
||||
'version-history:save',
|
||||
'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 = [
|
||||
@@ -266,10 +321,6 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'pdf-operation-error',
|
||||
'pdf-batch-complete',
|
||||
|
||||
// ASCII Art Generator
|
||||
'show-ascii-generator-window',
|
||||
'show-ascii-generator',
|
||||
|
||||
// Table Generator
|
||||
'show-table-generator-window',
|
||||
|
||||
@@ -292,6 +343,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
||||
'toggle-sidebar-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
|
||||
'run-plugin-export-format',
|
||||
];
|
||||
@@ -484,7 +541,80 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Generator Windows
|
||||
generators: {
|
||||
openAscii: () => ipcRenderer.send('open-ascii-generator'),
|
||||
openFlowchart: () => ipcRenderer.send('open-flowchart-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'),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 "exact phrase""
|
||||
autocomplete="off">
|
||||
<button class="search-run-btn" id="search-run" title="Run (Enter)">🔍</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };
|
||||
@@ -358,72 +358,6 @@ body.theme-concreteinfo .status-bar {
|
||||
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
|
||||
============================================ */
|
||||
@@ -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 */
|
||||
body.mono-jetbrains {
|
||||
--font-mono-active: 'JetBrains Mono', monospace;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1023,3 +1023,190 @@ body[class*='dark'] .preview-content a.wiki-link {
|
||||
color: var(--text-secondary, inherit);
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user