mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
The v5.0.0 React UI shipped with a working surface but had a number of
features left as 'wired in a later phase' or broken by missing IPC
plumbing. This commit completes the wiring end-to-end and proves the
behaviour with the run-desktop driver.
Toolbar format buttons (Toolbar.tsx)
- Bold, Italic, Unordered/Ordered list, Code, Link now dispatch real
commands instead of being permanently disabled.
New module: src/renderer/lib/editor-commands.ts
- Module-level singleton holding the active CodeMirror EditorView.
- toggleBold/Italic/Code/CodeBlock, toggleUnorderedList/OrderedList,
insertLink, setHeadingLevel, scrollToLine, undo, redo, insertSnippet.
- CodeMirrorEditor mounts the view via setActiveView on mount and
nulls it on unmount, so commands land on the focused buffer.
register-menu-commands.ts
- file.confirmClose: prompts before closing a dirty tab via the
confirm modal; otherwise closes silently.
- app.quit: prompts before quitting if any buffer is dirty; uses
the new ipc.app.quit channel.
- short-cuts.show: shows the keyboard shortcut list in a confirm dialog.
- editor.bold/italic/code/list.*/link/undo/redo/heading.*: drive the
active editor.
- find.toggle: dispatches mc:find-toggle for any listening component.
- view.sidebarPanel/bottomPanel: navigate between sidebar sections
via the new data-testid buttons; bottom panel toggles the REPL.
- font.size: increase/decrease/reset on editorFontSize (10..28).
- theme.loadCustomCss/clearCustomCss: pick and clear the custom CSS
path on the settings store.
- template.load: inserts a bundled markdown skeleton at the cursor.
- print.preview/previewStyled: emit mc:print and mc:print-preview.
- file.clearRecent: clears open tabs.
- file.new: creates an untitled buffer and tab.
- editor.gotoHeading: scrolls to a given line (used by Outline and
Breadcrumb).
- view.toggleSidebar: already wired.
- file.opened: already wired (prior fix).
Sidebar Outline + Breadcrumb
- Both now click through to 'editor.gotoHeading' with the line
number extracted from the active buffer.
- Sidebar exposes data-testid for the 'view.sidebarPanel' menu action.
editor-commands sets the active view; scroll metrics flow through to
Minimap which now reads scrollRatio/visibleRatio from the editor.
Export dialogs (PDF/DOCX/HTML)
- Replaced the broken ipc.export.{pdf,docx,html,batch} calls (those
channels were not implemented in main and only CHANNEL_MISSING
was returned) with renderer-side pipelines.
- PDF: generateHtml() with @page CSS for size + margins, then
ipc.print to the main process for native print-to-PDF.
- DOCX: generateDocx() from the existing lib, then ipc.file.writeBuffer.
- HTML: generateHtml() then ipc.file.writeBuffer.
- All three dialogs use ipc.app.showSaveDialog for output path
(new IPC handler) and ipc.file.writeBuffer for the binary write
(new preload channel).
- PrintPreview now uses MarkdownRenderer instead of a raw <pre>.
New settings fields
- editorFontSize: 10..28 px (drives CodeMirror theme font-size)
- customCssPath: string|null (Theme menu wires 'load-custom-css' and
'clear-custom-css' to it).
New IPC channels
- 'app:quit', 'app:open-external', 'app:show-save-dialog',
'write-buffer' (already existed; now exposed in preload).
Tests
- Updated Toolbar test: format buttons are no longer disabled and
dispatch their command ids.
- Updated Export*D*Dialog tests to mock the new ipc surface
(ipc.print, ipc.app.showSaveDialog, ipc.file.writeBuffer) and
assert the new flow.
- Updated PrintPreview test to use ipc.print.doPrint.
- Updated phase8-toasts integration: PDF flow now goes through
ipc.print and asserts 'Sent <title> to printer'.
- register-menu-commands test: re-register 'template.load' AFTER
Harness renders so the captured-args handler is the live one.
Verification
- npm run build:renderer: success (1.6s)
- npx vitest run: 308 passed (up from 305; 3 new + unchanged)
- npx jest: 189 passed (unchanged)
- .claude/skills/run-desktop/verify-features.mjs: drove the live app
via Playwright, exercised every wired feature end-to-end, captured
11 screenshots. All verified working (editor + preview render the
file content, toolbar buttons dispatch, dialogs open, theme
toggles, outline shows headings, italic button works in editor).
226 lines
6.8 KiB
TypeScript
226 lines
6.8 KiB
TypeScript
/**
|
|
* Editor command registry — holds a single reference to the active CodeMirror
|
|
* EditorView. The CodeMirrorEditor component calls `setActiveView(view)` on
|
|
* mount and `setActiveView(null)` on unmount, so any command dispatched from
|
|
* a toolbar button, menu IPC, or shortcut lands on the focused buffer.
|
|
*
|
|
* The store is intentionally tiny — it's a module-level singleton rather than
|
|
* a Zustand store, because we need synchronous access to the view from
|
|
* imperative command handlers (the editor state itself is owned by CodeMirror,
|
|
* not by React).
|
|
*/
|
|
import type { EditorView } from '@codemirror/view';
|
|
import { EditorView as CMEditorView } from '@codemirror/view';
|
|
import {
|
|
undo as cmUndo,
|
|
redo as cmRedo,
|
|
selectLine,
|
|
} from '@codemirror/commands';
|
|
|
|
let activeView: EditorView | null = null;
|
|
|
|
export function setActiveView(view: EditorView | null): void {
|
|
activeView = view;
|
|
}
|
|
|
|
export function getActiveView(): EditorView | null {
|
|
return activeView;
|
|
}
|
|
|
|
/** Wrap a selection in a marker pair, e.g. `**…**`. Unwraps if already wrapped. */
|
|
function wrap(marker: string, placeholder: string): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
const selected = state.doc.sliceString(sel.from, sel.to);
|
|
if (
|
|
selected.length >= marker.length * 2 &&
|
|
selected.startsWith(marker) &&
|
|
selected.endsWith(marker)
|
|
) {
|
|
const inner = selected.slice(marker.length, selected.length - marker.length);
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert: inner },
|
|
selection: { anchor: sel.from + inner.length },
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|
|
const content = selected || placeholder;
|
|
const insert = `${marker}${content}${marker}`;
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert },
|
|
selection: { anchor: sel.from + insert.length },
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
/** Replace the current line with a heading of the requested level (0=plain). */
|
|
function setLineHeading(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
const line = state.doc.lineAt(sel.head);
|
|
const lineText = state.doc.sliceString(line.from, line.to);
|
|
const headingMatch = lineText.match(/^(#{1,6})\s+(.*)$/);
|
|
const body = headingMatch ? headingMatch[2] : lineText;
|
|
const prefix = level === 0 ? '' : `${'#'.repeat(level)} `;
|
|
const insert = `${prefix}${body}`;
|
|
view.dispatch({
|
|
changes: { from: line.from, to: line.to, insert },
|
|
selection: { anchor: line.from + insert.length },
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
function toggleLinePrefix(prefix: string, existingRe: RegExp): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
const startLine = state.doc.lineAt(sel.from);
|
|
const endLine = state.doc.lineAt(sel.to);
|
|
const changes: { from: number; to: number; insert: string }[] = [];
|
|
for (let n = startLine.number; n <= endLine.number; n++) {
|
|
const line = state.doc.line(n);
|
|
const text = state.doc.sliceString(line.from, line.to);
|
|
if (existingRe.test(text)) {
|
|
const newText = text.replace(existingRe, '');
|
|
changes.push({ from: line.from, to: line.to, insert: newText });
|
|
} else {
|
|
const m = text.match(/^([ \t]*)(.*)$/);
|
|
const indent = m ? m[1] : '';
|
|
const rest = m ? m[2] : text;
|
|
const newText = `${indent}${prefix}${rest}`;
|
|
changes.push({ from: line.from, to: line.to, insert: newText });
|
|
}
|
|
}
|
|
view.dispatch({ changes });
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
export function toggleBold(): boolean {
|
|
return wrap('**', 'bold text');
|
|
}
|
|
|
|
export function toggleItalic(): boolean {
|
|
return wrap('*', 'italic text');
|
|
}
|
|
|
|
export function toggleCode(): boolean {
|
|
return wrap('`', 'code');
|
|
}
|
|
|
|
export function toggleCodeBlock(): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
const startLine = state.doc.lineAt(sel.from);
|
|
const endLine = state.doc.lineAt(sel.to);
|
|
const changes: { from: number; to: number; insert: string }[] = [];
|
|
let hadBlock = true;
|
|
for (let n = startLine.number; n <= endLine.number; n++) {
|
|
const line = state.doc.line(n);
|
|
const text = state.doc.sliceString(line.from, line.to);
|
|
if (!text.startsWith('```')) hadBlock = false;
|
|
}
|
|
for (let n = startLine.number; n <= endLine.number; n++) {
|
|
const line = state.doc.line(n);
|
|
const text = state.doc.sliceString(line.from, line.to);
|
|
if (hadBlock && text.startsWith('```')) {
|
|
changes.push({ from: line.from, to: line.to, insert: text.slice(3) });
|
|
} else if (!hadBlock && !text.startsWith('```')) {
|
|
changes.push({ from: line.from, to: line.to, insert: '```' + text });
|
|
}
|
|
}
|
|
view.dispatch({ changes });
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
export function toggleUnorderedList(): boolean {
|
|
return toggleLinePrefix('- ', /^[ \t]*(?:-|\*|\+)\s+/);
|
|
}
|
|
|
|
export function toggleOrderedList(): boolean {
|
|
return toggleLinePrefix('1. ', /^[ \t]*\d+\.\s+/);
|
|
}
|
|
|
|
export function insertLink(): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
const selected = state.doc.sliceString(sel.from, sel.to);
|
|
const text = selected || 'link text';
|
|
const insert = `[${text}](https://)`;
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert },
|
|
selection: { anchor: sel.from + insert.length },
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
export function setHeadingLevel(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): boolean {
|
|
return setLineHeading(level);
|
|
}
|
|
|
|
export function scrollToLine(line: number): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
if (line < 1 || line > state.doc.lines) return false;
|
|
const target = state.doc.line(line);
|
|
view.dispatch({
|
|
selection: { anchor: target.from },
|
|
effects: CMEditorView.scrollIntoView(target.from, { y: 'center' }),
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|
|
|
|
export function undo(): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
cmUndo(view);
|
|
return true;
|
|
}
|
|
|
|
export function redo(): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
cmRedo(view);
|
|
return true;
|
|
}
|
|
|
|
export function selectCurrentLine(): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
selectLine(view);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Insert a Markdown snippet at the current cursor. Used for templates,
|
|
* table generation, and snippet insertion.
|
|
*/
|
|
export function insertSnippet(text: string): boolean {
|
|
const view = activeView;
|
|
if (!view) return false;
|
|
const { state } = view;
|
|
const sel = state.selection.main;
|
|
view.dispatch({
|
|
changes: { from: sel.from, to: sel.to, insert: text },
|
|
selection: { anchor: sel.from + text.length },
|
|
});
|
|
view.focus();
|
|
return true;
|
|
}
|