diff --git a/package-lock.json b/package-lock.json
index b4098c4..5def1ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "supercmd",
- "version": "1.0.6",
+ "version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "supercmd",
- "version": "1.0.6",
+ "version": "1.0.7",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
@@ -17,6 +17,7 @@
"electron-liquid-glass": "^1.1.1",
"electron-updater": "^6.7.3",
"esbuild": "^0.19.12",
+ "katex": "^0.16.38",
"lucide-react": "^0.312.0",
"node-edge-tts": "^1.2.10",
"node-window-manager": "^2.2.4",
@@ -6022,6 +6023,31 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/katex": {
+ "version": "0.16.38",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz",
+ "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/package.json b/package.json
index 0f0e777..fdfc2db 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"electron-liquid-glass": "^1.1.1",
"electron-updater": "^6.7.3",
"esbuild": "^0.19.12",
+ "katex": "^0.16.38",
"lucide-react": "^0.312.0",
"node-edge-tts": "^1.2.10",
"node-window-manager": "^2.2.4",
diff --git a/src/main/main.ts b/src/main/main.ts
index 713f9ca..2b19753 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -775,6 +775,7 @@ const DETACHED_SPEAK_WINDOW_NAME = 'supercmd-speak-window';
const DETACHED_WINDOW_MANAGER_WINDOW_NAME = 'supercmd-window-manager-window';
const DETACHED_PROMPT_WINDOW_NAME = 'supercmd-prompt-window';
const DETACHED_MEMORY_STATUS_WINDOW_NAME = 'supercmd-memory-status-window';
+const DETACHED_NOTES_WINDOW_NAME = 'supercmd-notes-window';
const DETACHED_WINDOW_QUERY_KEY = 'sc_detached';
const MEMORY_STATUS_WINDOW_WIDTH = 340;
const MEMORY_STATUS_WINDOW_HEIGHT = 60;
@@ -812,12 +813,14 @@ function resolveDetachedPopupName(details: any): string | null {
byFrameName === DETACHED_WINDOW_MANAGER_WINDOW_NAME ||
byFrameName === DETACHED_PROMPT_WINDOW_NAME ||
byFrameName === DETACHED_MEMORY_STATUS_WINDOW_NAME ||
+ byFrameName === DETACHED_NOTES_WINDOW_NAME ||
byFrameName.startsWith(`${DETACHED_WHISPER_WINDOW_NAME}-`) ||
byFrameName.startsWith(`${DETACHED_WHISPER_ONBOARDING_WINDOW_NAME}-`) ||
byFrameName.startsWith(`${DETACHED_SPEAK_WINDOW_NAME}-`) ||
byFrameName.startsWith(`${DETACHED_WINDOW_MANAGER_WINDOW_NAME}-`) ||
byFrameName.startsWith(`${DETACHED_PROMPT_WINDOW_NAME}-`) ||
- byFrameName.startsWith(`${DETACHED_MEMORY_STATUS_WINDOW_NAME}-`)
+ byFrameName.startsWith(`${DETACHED_MEMORY_STATUS_WINDOW_NAME}-`) ||
+ byFrameName.startsWith(`${DETACHED_NOTES_WINDOW_NAME}-`)
) {
if (byFrameName.startsWith(DETACHED_WHISPER_WINDOW_NAME)) return DETACHED_WHISPER_WINDOW_NAME;
if (byFrameName.startsWith(DETACHED_WHISPER_ONBOARDING_WINDOW_NAME)) return DETACHED_WHISPER_ONBOARDING_WINDOW_NAME;
@@ -825,6 +828,7 @@ function resolveDetachedPopupName(details: any): string | null {
if (byFrameName.startsWith(DETACHED_WINDOW_MANAGER_WINDOW_NAME)) return DETACHED_WINDOW_MANAGER_WINDOW_NAME;
if (byFrameName.startsWith(DETACHED_PROMPT_WINDOW_NAME)) return DETACHED_PROMPT_WINDOW_NAME;
if (byFrameName.startsWith(DETACHED_MEMORY_STATUS_WINDOW_NAME)) return DETACHED_MEMORY_STATUS_WINDOW_NAME;
+ if (byFrameName.startsWith(DETACHED_NOTES_WINDOW_NAME)) return DETACHED_NOTES_WINDOW_NAME;
return byFrameName;
}
const rawUrl = String(details?.url || '').trim();
@@ -838,7 +842,8 @@ function resolveDetachedPopupName(details: any): string | null {
byQuery === DETACHED_SPEAK_WINDOW_NAME ||
byQuery === DETACHED_WINDOW_MANAGER_WINDOW_NAME ||
byQuery === DETACHED_PROMPT_WINDOW_NAME ||
- byQuery === DETACHED_MEMORY_STATUS_WINDOW_NAME
+ byQuery === DETACHED_MEMORY_STATUS_WINDOW_NAME ||
+ byQuery === DETACHED_NOTES_WINDOW_NAME
) {
return byQuery;
}
@@ -5180,6 +5185,8 @@ function createWindow(): void {
? CURSOR_PROMPT_WINDOW_WIDTH
: detachedPopupName === DETACHED_MEMORY_STATUS_WINDOW_NAME
? 340
+ : detachedPopupName === DETACHED_NOTES_WINDOW_NAME
+ ? 420
: 520;
const defaultHeight = detachedPopupName === DETACHED_WHISPER_WINDOW_NAME
? 52
@@ -5191,6 +5198,8 @@ function createWindow(): void {
? CURSOR_PROMPT_WINDOW_HEIGHT
: detachedPopupName === DETACHED_MEMORY_STATUS_WINDOW_NAME
? 60
+ : detachedPopupName === DETACHED_NOTES_WINDOW_NAME
+ ? 560
: 112;
const finalWidth = typeof popupBounds.width === 'number' ? popupBounds.width : defaultWidth;
const finalHeight = typeof popupBounds.height === 'number' ? popupBounds.height : defaultHeight;
@@ -5215,6 +5224,8 @@ function createWindow(): void {
? 'SuperCmd Window Manager'
: detachedPopupName === DETACHED_MEMORY_STATUS_WINDOW_NAME
? 'SuperCmd Status'
+ : detachedPopupName === DETACHED_NOTES_WINDOW_NAME
+ ? 'SuperCmd Notes'
: 'SuperCmd Read',
frame: false,
titleBarStyle: 'hidden',
@@ -5230,8 +5241,8 @@ function createWindow(): void {
detachedPopupName === DETACHED_WHISPER_ONBOARDING_WINDOW_NAME || useNativeVibrancyForWindowManager
? 'active'
: undefined,
- hasShadow: false,
- resizable: false,
+ hasShadow: detachedPopupName === DETACHED_NOTES_WINDOW_NAME,
+ resizable: detachedPopupName === DETACHED_NOTES_WINDOW_NAME,
minimizable: false,
maximizable: false,
fullscreenable: false,
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 949e036..7d83b3a 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -392,6 +392,17 @@ const App: React.FC = () => {
},
});
+ const notesPortalTarget = useDetachedPortalWindow(!!showNotesManager, {
+ name: 'supercmd-notes-window',
+ title: 'SuperCmd Notes',
+ width: 420,
+ height: 560,
+ anchor: 'center',
+ onClosed: () => {
+ setShowNotesManager(null);
+ },
+ });
+
const windowManagerPortalTarget = useDetachedPortalWindow(showWindowManager, {
name: 'supercmd-window-manager-window',
title: 'SuperCmd Window Manager',
@@ -616,10 +627,12 @@ const App: React.FC = () => {
}
if (routedSystemCommandId === 'system-search-notes') {
openNotesManager('search');
+ window.electron.hideWindow();
return;
}
if (routedSystemCommandId === 'system-create-note') {
openNotesManager('create');
+ window.electron.hideWindow();
return;
}
if (routedSystemCommandId === 'system-search-snippets') {
@@ -1921,11 +1934,13 @@ const App: React.FC = () => {
if (commandId === 'system-search-notes') {
whisperSessionRef.current = false;
openNotesManager('search');
+ window.electron.hideWindow();
return true;
}
if (commandId === 'system-create-note') {
whisperSessionRef.current = false;
openNotesManager('create');
+ window.electron.hideWindow();
return true;
}
if (commandId === 'system-search-snippets') {
@@ -2752,6 +2767,24 @@ const App: React.FC = () => {
cursorPromptPortalTarget
)
: null}
+ {showNotesManager && notesPortalTarget
+ ? createPortal(
+
+
+ {
+ setShowNotesManager(null);
+ setSearchQuery('');
+ setSelectedIndex(0);
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }}
+ />
+
+
,
+ notesPortalTarget
+ )
+ : null}
>
);
@@ -2969,27 +3002,7 @@ const App: React.FC = () => {
);
}
- // ─── Notes Manager mode ──────────────────────────────────────────
- if (showNotesManager) {
- return (
- <>
- {alwaysMountedRunners}
-
-
- {
- setShowNotesManager(null);
- setSearchQuery('');
- setSelectedIndex(0);
- setTimeout(() => inputRef.current?.focus(), 50);
- }}
- />
-
-
- >
- );
- }
+ // ─── Notes Manager — rendered in detached floating window ────────
// ─── Snippet Manager mode ─────────────────────────────────────────
if (showSnippetManager) {
diff --git a/src/renderer/src/NotesManager.tsx b/src/renderer/src/NotesManager.tsx
index 2cc84ab..910568e 100644
--- a/src/renderer/src/NotesManager.tsx
+++ b/src/renderer/src/NotesManager.tsx
@@ -1,25 +1,32 @@
/**
- * Notes Manager UI — Raycast Notes clone (exact parity)
+ * Notes Manager — Notion-like block editor with SuperCmd native UI
*
- * Editor: WYSIWYG inline markdown rendering via contentEditable
- * Action Panel: drops down from title bar, exact Raycast items/order
- * All shortcuts match Raycast exactly
+ * Features:
+ * - Block-based contentEditable editor with live rendering
+ * - Markdown shortcuts auto-convert (# → heading, - → bullet, - [ ] → checkbox, etc.)
+ * - Notion-like slash command menu (/heading, /bullet, /todo, etc.)
+ * - Drag-and-drop block reordering
+ * - Clickable checkboxes
+ * - Native SuperCmd styling (CSS variables, glass footer, back button)
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
- X, ArrowLeft, Plus, FileText, Pin, PinOff,
+ ArrowLeft, Plus, FileText, Pin, PinOff,
Copy, Trash2, Files, Download, Upload,
Bold, Italic, Strikethrough, Underline, Code,
Link, Quote, ListOrdered, List, ListChecks,
SquareCode, Command, LayoutList, Search,
Type, ArrowUp, ArrowDown, Link2, Info,
+ GripVertical, Minus, X, Sigma,
} from 'lucide-react';
+import katex from 'katex';
+import 'katex/dist/katex.min.css';
import type { Note, NoteTheme } from '../types/electron';
import ExtensionActionFooter from './components/ExtensionActionFooter';
-// ─── Props ──────────────────────────────────────────────────────────
+// ─── Types ───────────────────────────────────────────────────────────
interface NotesManagerProps {
onClose: () => void;
@@ -36,19 +43,21 @@ interface Action {
disabled?: boolean;
}
-// ─── Theme Accent Colors ────────────────────────────────────────────
+type BlockType = 'paragraph' | 'h1' | 'h2' | 'h3' | 'bullet' | 'ordered' | 'checkbox' | 'code' | 'blockquote' | 'divider' | 'math';
+
+interface Block {
+ id: string;
+ type: BlockType;
+ content: string;
+ checked?: boolean;
+}
+
+// ─── Theme ───────────────────────────────────────────────────────────
const THEME_ACCENT: Record = {
- default: '#a0a0a0',
- rose: '#fb7185',
- orange: '#fb923c',
- amber: '#fbbf24',
- emerald: '#34d399',
- cyan: '#22d3ee',
- blue: '#60a5fa',
- violet: '#a78bfa',
- fuchsia: '#e879f9',
- slate: '#94a3b8',
+ default: '#a0a0a0', rose: '#fb7185', orange: '#fb923c', amber: '#fbbf24',
+ emerald: '#34d399', cyan: '#22d3ee', blue: '#60a5fa', violet: '#a78bfa',
+ fuchsia: '#e879f9', slate: '#94a3b8',
};
const THEME_DOTS: Array<{ id: NoteTheme; label: string; color: string }> = [
@@ -64,7 +73,7 @@ const THEME_DOTS: Array<{ id: NoteTheme; label: string; color: string }> = [
{ id: 'slate', label: 'Slate', color: '#94a3b8' },
];
-// ─── Helpers ────────────────────────────────────────────────────────
+// ─── Helpers ─────────────────────────────────────────────────────────
function charCount(s: string) { return s.length; }
function wordCount(s: string) { return s.trim() ? s.trim().split(/\s+/).length : 0; }
@@ -89,10 +98,9 @@ function formatRelativeTime(ts: number): string {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
if (minutes < 1) return 'Just now';
- if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
- if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
- return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' at ' +
- new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ if (minutes < 60) return `${minutes}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function groupNotesByDate(notes: Note[]): Array<{ label: string; notes: Note[] }> {
@@ -111,116 +119,969 @@ function groupNotesByDate(notes: Note[]): Array<{ label: string; notes: Note[] }
}
function extractTitleFromContent(content: string): string {
- const lines = content.split('\n');
- for (const line of lines) {
- const trimmed = line.trim();
- if (trimmed) return trimmed.replace(/^#{1,6}\s+/, '') || 'Untitled';
+ for (const line of content.split('\n')) {
+ const t = line.trim();
+ if (t) return t.replace(/^#{1,6}\s+/, '').replace(/^[-*+]\s+/, '').replace(/^- \[[ x]\]\s+/, '').replace(/^>\s+/, '').replace(/^\d+\.\s+/, '') || 'Untitled';
}
return 'Untitled';
}
-// ─── Markdown → HTML for WYSIWYG preview ────────────────────────────
+// ─── KaTeX Helpers ───────────────────────────────────────────────────
-function markdownToHtml(md: string, accentColor: string): string {
- if (!md.trim()) return 'Start writing...';
+function renderKatex(latex: string, displayMode: boolean = false): string {
+ try {
+ return katex.renderToString(latex, { displayMode, throwOnError: false, strict: false });
+ } catch {
+ return `${latex}`;
+ }
+}
- const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>');
+function renderInlineMath(text: string): string {
+ // Replace $...$ (not $$) with rendered KaTeX inline spans
+ return text.replace(/\$([^\$]+?)\$/g, (_match, latex) => renderKatex(latex.trim(), false));
+}
- const inlineFormat = (text: string): string => {
- let s = escapeHtml(text);
- // inline code (before bold/italic to avoid conflicts)
- s = s.replace(/`([^`]+)`/g, `$1`);
- // bold
- s = s.replace(/\*\*(.+?)\*\*/g, '$1');
- // italic
- s = s.replace(/\*(.+?)\*/g, '$1');
- // strikethrough
- s = s.replace(/~~(.+?)~~/g, '$1');
- // links
- s = s.replace(/\[(.+?)\]\((.+?)\)/g, `$1`);
- return s;
- };
+// ─── Block System ────────────────────────────────────────────────────
+
+let _blockIdCounter = 0;
+const genBlockId = () => `blk-${++_blockIdCounter}-${Date.now().toString(36)}`;
+function parseMarkdownToBlocks(md: string): Block[] {
+ if (!md.trim()) return [{ id: genBlockId(), type: 'paragraph', content: '' }];
const lines = md.split('\n');
- const parts: string[] = [];
+ const blocks: Block[] = [];
let i = 0;
-
while (i < lines.length) {
const line = lines[i];
-
- // Code block
+ // Math block ($$...$$)
+ if (line.trimStart() === '$$') {
+ const mathLines: string[] = [];
+ let j = i + 1;
+ while (j < lines.length && lines[j].trimStart() !== '$$') { mathLines.push(lines[j]); j++; }
+ blocks.push({ id: genBlockId(), type: 'math', content: mathLines.join('\n') });
+ i = j + 1; continue;
+ }
+ // Code fence
if (line.startsWith('```') || line.startsWith('~~~')) {
const fence = line.startsWith('```') ? '```' : '~~~';
const codeLines: string[] = [];
let j = i + 1;
- while (j < lines.length && !lines[j].startsWith(fence)) { codeLines.push(escapeHtml(lines[j])); j++; }
- parts.push(`${codeLines.join('\n')}`);
+ while (j < lines.length && !lines[j].startsWith(fence)) { codeLines.push(lines[j]); j++; }
+ blocks.push({ id: genBlockId(), type: 'code', content: codeLines.join('\n') });
i = j + 1; continue;
}
-
+ // Divider
+ if (/^(---+|___+|\*\*\*+)$/.test(line.trim())) { blocks.push({ id: genBlockId(), type: 'divider', content: '' }); i++; continue; }
// Headings
const h3 = line.match(/^### (.+)/);
- if (h3) { parts.push(`${inlineFormat(h3[1])}
`); i++; continue; }
+ if (h3) { blocks.push({ id: genBlockId(), type: 'h3', content: h3[1] }); i++; continue; }
const h2 = line.match(/^## (.+)/);
- if (h2) { parts.push(`${inlineFormat(h2[1])}
`); i++; continue; }
+ if (h2) { blocks.push({ id: genBlockId(), type: 'h2', content: h2[1] }); i++; continue; }
const h1 = line.match(/^# (.+)/);
- if (h1) { parts.push(`${inlineFormat(h1[1])}
`); i++; continue; }
+ if (h1) { blocks.push({ id: genBlockId(), type: 'h1', content: h1[1] }); i++; continue; }
+ // Checkbox
+ const check = line.match(/^- \[([ x])\]\s*(.*)/);
+ if (check) { blocks.push({ id: genBlockId(), type: 'checkbox', content: check[2], checked: check[1] === 'x' }); i++; continue; }
+ // Bullet
+ const bullet = line.match(/^[-*+]\s+(.*)/);
+ if (bullet) { blocks.push({ id: genBlockId(), type: 'bullet', content: bullet[1] }); i++; continue; }
+ // Ordered
+ const ordered = line.match(/^(\d+)\.\s+(.*)/);
+ if (ordered) { blocks.push({ id: genBlockId(), type: 'ordered', content: ordered[2] }); i++; continue; }
+ // Blockquote
+ const bq = line.match(/^>\s*(.*)/);
+ if (bq) { blocks.push({ id: genBlockId(), type: 'blockquote', content: bq[1] }); i++; continue; }
+ // Empty line → empty paragraph
+ if (!line.trim()) { blocks.push({ id: genBlockId(), type: 'paragraph', content: '' }); i++; continue; }
+ // Paragraph
+ blocks.push({ id: genBlockId(), type: 'paragraph', content: line });
+ i++;
+ }
+ if (blocks.length === 0) blocks.push({ id: genBlockId(), type: 'paragraph', content: '' });
+ return blocks;
+}
+
+function serializeBlocksToMarkdown(blocks: Block[]): string {
+ return blocks.map((b, i) => {
+ switch (b.type) {
+ case 'h1': return `# ${b.content}`;
+ case 'h2': return `## ${b.content}`;
+ case 'h3': return `### ${b.content}`;
+ case 'bullet': return `- ${b.content}`;
+ case 'ordered': {
+ // Count preceding ordered blocks for numbering
+ let num = 1;
+ for (let j = i - 1; j >= 0 && blocks[j].type === 'ordered'; j--) num++;
+ return `${num}. ${b.content}`;
+ }
+ case 'checkbox': return `- [${b.checked ? 'x' : ' '}] ${b.content}`;
+ case 'blockquote': return `> ${b.content}`;
+ case 'code': return '```\n' + b.content + '\n```';
+ case 'math': return '$$\n' + b.content + '\n$$';
+ case 'divider': return '---';
+ default: return b.content;
+ }
+ }).join('\n');
+}
- // Horizontal rule
- if (/^(---+|___+|\*\*\*+)$/.test(line.trim())) { parts.push('
'); i++; continue; }
+// Detect markdown prefix typed at start of paragraph
+function detectMarkdownPrefix(text: string): { type: BlockType; content: string; checked?: boolean } | null {
+ if (text.startsWith('### ')) return { type: 'h3', content: text.slice(4) };
+ if (text.startsWith('## ')) return { type: 'h2', content: text.slice(3) };
+ if (text.startsWith('# ')) return { type: 'h1', content: text.slice(2) };
+ if (text.startsWith('- [x] ')) return { type: 'checkbox', content: text.slice(6), checked: true };
+ if (text.startsWith('- [ ] ')) return { type: 'checkbox', content: text.slice(6), checked: false };
+ if (text.startsWith('[] ')) return { type: 'checkbox', content: text.slice(3), checked: false };
+ if (/^[-*+] /.test(text)) return { type: 'bullet', content: text.slice(2) };
+ if (/^\d+\. /.test(text)) { const m = text.match(/^\d+\. /); return m ? { type: 'ordered', content: text.slice(m[0].length) } : null; }
+ if (text.startsWith('> ')) return { type: 'blockquote', content: text.slice(2) };
+ if (text === '---' || text === '***' || text === '___') return { type: 'divider', content: '' };
+ if (text === '$$') return { type: 'math', content: '' };
+ return null;
+}
- // Checklist
- const check = line.match(/^- \[([ x])\]\s*(.*)/);
- if (check) {
- const done = check[1] === 'x';
- const checkStyle = done
- ? 'border:2px solid #fb7185;background:rgba(251,113,133,0.2);color:#fda4af;border-radius:4px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;flex-shrink:0;margin-top:2px'
- : 'border:2px solid rgba(251,113,133,0.4);border-radius:4px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px';
- const textStyle = done ? 'color:rgba(255,255,255,0.4);text-decoration:line-through' : 'color:rgba(255,255,255,0.8)';
- parts.push(`${done ? '✓' : ''}${inlineFormat(check[2])}
`);
- i++; continue;
+function getCursorOffset(el: HTMLElement | null | undefined): number {
+ if (!el) return 0;
+ const sel = window.getSelection();
+ if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return 0;
+ const range = sel.getRangeAt(0);
+ if (!el.contains(range.startContainer)) return 0;
+ if (range.startContainer === el) return range.startOffset;
+ // Text node child
+ return range.startOffset;
+}
+
+function setCursorPosition(el: HTMLElement, offset: number) {
+ requestAnimationFrame(() => {
+ el.focus();
+ const textNode = el.firstChild;
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
+ const range = document.createRange();
+ const safe = Math.min(offset, textNode.textContent?.length || 0);
+ range.setStart(textNode, safe);
+ range.collapse(true);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ } else {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ range.collapse(offset > 0 ? false : true);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
}
+ });
+}
- // Bullet list
- const ul = line.match(/^[-*+]\s+(.+)/);
- if (ul) {
- parts.push(`${inlineFormat(ul[1])}
`);
- i++; continue;
+// ─── Slash Command Menu ──────────────────────────────────────────────
+
+const SLASH_COMMANDS: Array<{ type: BlockType; label: string; description: string; icon: React.ReactNode; keywords: string[] }> = [
+ { type: 'paragraph', label: 'Text', description: 'Plain text block', icon: , keywords: ['text', 'paragraph', 'plain'] },
+ { type: 'h1', label: 'Heading 1', description: 'Large heading', icon: H1, keywords: ['heading', 'h1', 'title'] },
+ { type: 'h2', label: 'Heading 2', description: 'Medium heading', icon: H2, keywords: ['heading', 'h2'] },
+ { type: 'h3', label: 'Heading 3', description: 'Small heading', icon: H3, keywords: ['heading', 'h3'] },
+ { type: 'bullet', label: 'Bullet List', description: 'Unordered list item', icon:
, keywords: ['bullet', 'list', 'unordered'] },
+ { type: 'ordered', label: 'Numbered List', description: 'Ordered list item', icon: , keywords: ['numbered', 'ordered', 'list'] },
+ { type: 'checkbox', label: 'To-Do', description: 'Checkbox item', icon: , keywords: ['todo', 'checkbox', 'task', 'check'] },
+ { type: 'blockquote', label: 'Quote', description: 'Block quote', icon:
, keywords: ['quote', 'blockquote'] },
+ { type: 'code', label: 'Code', description: 'Code block', icon: , keywords: ['code', 'snippet'] },
+ { type: 'math', label: 'Math Block', description: 'LaTeX math equation', icon: , keywords: ['math', 'latex', 'equation', 'formula', 'katex'] },
+ { type: 'divider', label: 'Divider', description: 'Horizontal line', icon: , keywords: ['divider', 'line', 'separator', 'hr'] },
+];
+
+interface SlashMenuProps {
+ query: string;
+ position: { top: number; left: number };
+ onSelect: (type: BlockType) => void;
+ onClose: () => void;
+}
+
+const SlashMenu: React.FC = ({ query, position, onSelect, onClose }) => {
+ const [selectedIdx, setSelectedIdx] = useState(0);
+ const listRef = useRef(null);
+
+ const filtered = useMemo(() => {
+ if (!query) return SLASH_COMMANDS;
+ const q = query.toLowerCase();
+ return SLASH_COMMANDS.filter(c => c.label.toLowerCase().includes(q) || c.keywords.some(k => k.includes(q)));
+ }, [query]);
+
+ useEffect(() => { setSelectedIdx(0); }, [query]);
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return; }
+ if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setSelectedIdx(i => Math.min(i + 1, filtered.length - 1)); return; }
+ if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setSelectedIdx(i => Math.max(0, i - 1)); return; }
+ if (e.key === 'Enter' || e.key === 'Tab') {
+ if (filtered[selectedIdx]) { e.preventDefault(); e.stopPropagation(); onSelect(filtered[selectedIdx].type); }
+ return;
+ }
+ };
+ window.addEventListener('keydown', handler, true);
+ return () => window.removeEventListener('keydown', handler, true);
+ }, [filtered, selectedIdx, onSelect, onClose]);
+
+ useEffect(() => {
+ const items = listRef.current?.querySelectorAll('[data-slash-item]');
+ const item = items?.[selectedIdx] as HTMLElement;
+ item?.scrollIntoView({ block: 'nearest' });
+ }, [selectedIdx]);
+
+ if (filtered.length === 0) return null;
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+ Blocks
+
+
+ {filtered.map((cmd, idx) => (
+
onSelect(cmd.type)}
+ onMouseEnter={() => setSelectedIdx(idx)}
+ className={`flex items-center gap-2.5 px-2.5 py-1.5 cursor-pointer transition-colors ${idx === selectedIdx ? 'bg-[var(--accent)]/10' : ''}`}
+ >
+
{cmd.icon}
+
+
{cmd.label}
+
{cmd.description}
+
+
+ ))}
+
+
+
,
+ document.body
+ );
+};
+
+// ─── Block Editor ────────────────────────────────────────────────────
+
+interface BlockEditorProps {
+ initialContent: string;
+ onContentChange: (content: string) => void;
+ accentColor: string;
+}
+
+const BlockEditor: React.FC = ({ initialContent, onContentChange, accentColor }) => {
+ const [blocks, setBlocks] = useState(() => parseMarkdownToBlocks(initialContent));
+ const blocksRef = useRef(blocks);
+ useEffect(() => { blocksRef.current = blocks; }, [blocks]);
+
+ const [slashMenu, setSlashMenu] = useState<{ blockId: string; query: string; position: { top: number; left: number } } | null>(null);
+ const [dragIdx, setDragIdx] = useState(null);
+ const [dropIdx, setDropIdx] = useState(null);
+
+ const blockElsRef = useRef