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>(new Map()); + const pendingFocusRef = useRef<{ id: string; offset: number } | null>(null); + const saveTimerRef = useRef | null>(null); + const [focusedBlockId, setFocusedBlockId] = useState(null); + + // ─── Undo / Redo History ─────────────────────────────────── + const historyRef = useRef([]); + const historyIdxRef = useRef(-1); + const historyTimerRef = useRef | null>(null); + const isUndoRedoRef = useRef(false); + + // Snapshot current DOM content into blocks for accurate history + const snapshotBlocks = useCallback((): Block[] => { + return blocksRef.current.map(b => { + const el = blockElsRef.current.get(b.id); + return { ...b, content: el?.textContent ?? b.content }; + }); + }, []); + + // Push a snapshot to history (debounced for typing, immediate for structural changes) + const pushHistory = useCallback((immediate?: boolean) => { + if (isUndoRedoRef.current) return; + const push = () => { + const snapshot = snapshotBlocks().map(b => ({ ...b })); + const stack = historyRef.current.slice(0, historyIdxRef.current + 1); + stack.push(snapshot); + // Cap at 100 entries + if (stack.length > 100) stack.shift(); + historyRef.current = stack; + historyIdxRef.current = stack.length - 1; + }; + if (immediate) { + if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; } + push(); + } else { + if (historyTimerRef.current) clearTimeout(historyTimerRef.current); + historyTimerRef.current = setTimeout(push, 500); } + }, [snapshotBlocks]); - // Ordered list - const ol = line.match(/^(\d+)\.\s+(.+)/); - if (ol) { - parts.push(`
${ol[1]}.${inlineFormat(ol[2])}
`); - i++; continue; + // Initialize history with initial state + useEffect(() => { + const initial = blocksRef.current.map(b => ({ ...b })); + historyRef.current = [initial]; + historyIdxRef.current = 0; + }, []); + + const undo = useCallback(() => { + if (historyIdxRef.current <= 0) return; + // Before undoing, make sure current state is saved + if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; } + // Save current as top if we haven't already + const currentSnapshot = snapshotBlocks().map(b => ({ ...b })); + const stack = historyRef.current; + // Replace current position with latest DOM state + stack[historyIdxRef.current] = currentSnapshot; + + historyIdxRef.current--; + const prev = stack[historyIdxRef.current]; + isUndoRedoRef.current = true; + setBlocks(prev.map(b => ({ ...b }))); + // Sync DOM + requestAnimationFrame(() => { + for (const b of prev) { + const el = blockElsRef.current.get(b.id); + if (el && el.textContent !== b.content) el.textContent = b.content; + } + isUndoRedoRef.current = false; + }); + }, [snapshotBlocks]); + + const redo = useCallback(() => { + if (historyIdxRef.current >= historyRef.current.length - 1) return; + historyIdxRef.current++; + const next = historyRef.current[historyIdxRef.current]; + isUndoRedoRef.current = true; + setBlocks(next.map(b => ({ ...b }))); + requestAnimationFrame(() => { + for (const b of next) { + const el = blockElsRef.current.get(b.id); + if (el && el.textContent !== b.content) el.textContent = b.content; + } + isUndoRedoRef.current = false; + }); + }, []); + + // Debounced save + useEffect(() => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + onContentChange(serializeBlocksToMarkdown(blocks)); + }, 300); + return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; + }, [blocks]); + + // Pending focus after render + useEffect(() => { + const pending = pendingFocusRef.current; + if (!pending) return; + pendingFocusRef.current = null; + setTimeout(() => { + const el = blockElsRef.current.get(pending.id); + if (el) setCursorPosition(el, pending.offset); + }, 0); + }); + + const focusBlock = useCallback((id: string, offset: number) => { + pendingFocusRef.current = { id, offset }; + }, []); + + // ─── Block Input Handler ───────────────────────────────────── + const handleBlockInput = useCallback((blockId: string) => { + const el = blockElsRef.current.get(blockId); + if (!el) return; + const text = el.textContent || ''; + const block = blocksRef.current.find(b => b.id === blockId); + if (!block) return; + + // Markdown prefix detection (only for paragraph blocks) + if (block.type === 'paragraph') { + const prefix = detectMarkdownPrefix(text); + if (prefix) { + el.textContent = prefix.content; + setCursorPosition(el, prefix.content.length); + setBlocks(prev => prev.map(b => + b.id === blockId ? { ...b, type: prefix.type, content: prefix.content, checked: prefix.checked } : b + )); + setSlashMenu(null); + return; + } } - // Blockquote - const bq = line.match(/^>\s*(.*)/); - if (bq) { - parts.push(`
${inlineFormat(bq[1])}
`); - i++; continue; + // Slash command detection + if (text === '/') { + const rect = el.getBoundingClientRect(); + setSlashMenu({ blockId, query: '', position: { top: rect.bottom + 4, left: rect.left } }); + } else if (text.startsWith('/') && !text.includes(' ')) { + const rect = el.getBoundingClientRect(); + setSlashMenu({ blockId, query: text.slice(1), position: { top: rect.bottom + 4, left: rect.left } }); + } else if (slashMenu?.blockId === blockId) { + setSlashMenu(null); } - // Empty line - if (!line.trim()) { parts.push('
'); i++; continue; } + // Normal content update + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, content: text } : b)); + pushHistory(); // debounced + }, [slashMenu, pushHistory]); + + // ─── Slash Command Selection ───────────────────────────────── + const handleSlashSelect = useCallback((type: BlockType) => { + if (!slashMenu) return; + const { blockId } = slashMenu; + const el = blockElsRef.current.get(blockId); + setSlashMenu(null); + + if (type === 'divider') { + // Replace current block with divider + new paragraph + const newPara: Block = { id: genBlockId(), type: 'paragraph', content: '' }; + setBlocks(prev => { + const idx = prev.findIndex(b => b.id === blockId); + const updated = [...prev]; + updated[idx] = { ...prev[idx], type: 'divider', content: '' }; + updated.splice(idx + 1, 0, newPara); + return updated; + }); + focusBlock(newPara.id, 0); + } else { + if (el) el.textContent = ''; + setBlocks(prev => prev.map(b => + b.id === blockId ? { ...b, type, content: '', checked: type === 'checkbox' ? false : undefined } : b + )); + focusBlock(blockId, 0); + } + }, [slashMenu, focusBlock]); + + // ─── Key Down Handler ──────────────────────────────────────── + const handleKeyDown = useCallback((e: React.KeyboardEvent, blockId: string) => { + const block = blocksRef.current.find(b => b.id === blockId); + if (!block) return; + const el = blockElsRef.current.get(blockId); + const meta = e.metaKey || e.ctrlKey; + + // If slash menu is open, let it handle navigation keys + if (slashMenu?.blockId === blockId && ['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) return; + + // ─── Undo: ⌘Z ─────────────────────────────────────── + if (meta && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + undo(); + return; + } - // Paragraph - parts.push(`

${inlineFormat(line)}

`); - i++; - } + // ─── Redo: ⌘⇧Z ────────────────────────────────────── + if (meta && e.shiftKey && e.key === 'z') { + e.preventDefault(); + redo(); + return; + } - return parts.join(''); -} + // ─── Enter: split block ────────────────────────────── + if (e.key === 'Enter' && !e.shiftKey && !meta) { + e.preventDefault(); + pushHistory(true); // save state before split + const offset = getCursorOffset(el); + const text = el?.textContent || ''; + const before = text.slice(0, offset); + const after = text.slice(offset); + + // Empty list/checkbox → convert to paragraph + if (['bullet', 'ordered', 'checkbox'].includes(block.type) && !before && !after) { + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, type: 'paragraph' } : b)); + pushHistory(true); + return; + } + + // Continue same type for lists + const newType = ['bullet', 'ordered', 'checkbox'].includes(block.type) ? block.type : 'paragraph'; + const newBlock: Block = { + id: genBlockId(), + type: newType as BlockType, + content: after, + checked: newType === 'checkbox' ? false : undefined, + }; + + // Update current block content + insert new block + if (el) el.textContent = before; + setBlocks(prev => { + const idx = prev.findIndex(b => b.id === blockId); + const updated = [...prev]; + updated[idx] = { ...block, content: before }; + updated.splice(idx + 1, 0, newBlock); + return updated; + }); + focusBlock(newBlock.id, 0); + pushHistory(true); + return; + } -// Also keep the React version for search preview -function renderMarkdownPreview(md: string, accentColor: string): React.ReactNode { - if (!md.trim()) return Start writing...; - // Use dangerouslySetInnerHTML for the HTML version - return
; + // ─── Backspace at start ────────────────────────────── + if (e.key === 'Backspace' && !meta) { + const offset = getCursorOffset(el); + const sel = window.getSelection(); + if (offset === 0 && sel?.isCollapsed) { + e.preventDefault(); + pushHistory(true); // save state before merge/convert + if (block.type !== 'paragraph') { + // Convert to paragraph + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, type: 'paragraph', checked: undefined } : b)); + } else { + // Merge with previous block + const idx = blocksRef.current.findIndex(b => b.id === blockId); + if (idx > 0) { + const prev = blocksRef.current[idx - 1]; + if (prev.type === 'divider') { + setBlocks(p => p.filter((_, i) => i !== idx - 1)); + focusBlock(blockId, 0); + } else { + const mergeOffset = prev.content.length; + const mergedContent = prev.content + block.content; + const prevEl = blockElsRef.current.get(prev.id); + if (prevEl) prevEl.textContent = mergedContent; + setBlocks(p => { + const u = [...p]; + u[idx - 1] = { ...prev, content: mergedContent }; + u.splice(idx, 1); + return u; + }); + focusBlock(prev.id, mergeOffset); + } + } + } + pushHistory(true); + return; + } + } + + // ─── Arrow navigation between blocks ───────────────── + + // ArrowDown at end of block → start of next block + if (e.key === 'ArrowDown' && !meta && !e.shiftKey) { + const offset = getCursorOffset(el); + const textLen = el?.textContent?.length || 0; + if (offset >= textLen) { + const idx = blocksRef.current.findIndex(b => b.id === blockId); + if (idx < blocksRef.current.length - 1) { + e.preventDefault(); + const next = blocksRef.current[idx + 1]; + focusBlock(next.id, 0); + } + } + } + + // ArrowUp at start of block → end of previous block + if (e.key === 'ArrowUp' && !meta && !e.shiftKey) { + const offset = getCursorOffset(el); + if (offset === 0) { + const idx = blocksRef.current.findIndex(b => b.id === blockId); + if (idx > 0) { + e.preventDefault(); + const prev = blocksRef.current[idx - 1]; + focusBlock(prev.id, prev.content.length); + } + } + } + + // ArrowLeft at start of block → end of previous block + if (e.key === 'ArrowLeft' && !meta && !e.shiftKey && !e.altKey) { + const offset = getCursorOffset(el); + const sel = window.getSelection(); + if (offset === 0 && sel?.isCollapsed) { + const idx = blocksRef.current.findIndex(b => b.id === blockId); + if (idx > 0) { + e.preventDefault(); + const prev = blocksRef.current[idx - 1]; + focusBlock(prev.id, prev.content.length); + } + } + } + + // ArrowRight at end of block → start of next block + if (e.key === 'ArrowRight' && !meta && !e.shiftKey && !e.altKey) { + const offset = getCursorOffset(el); + const textLen = el?.textContent?.length || 0; + const sel = window.getSelection(); + if (offset >= textLen && sel?.isCollapsed) { + const idx = blocksRef.current.findIndex(b => b.id === blockId); + if (idx < blocksRef.current.length - 1) { + e.preventDefault(); + const next = blocksRef.current[idx + 1]; + focusBlock(next.id, 0); + } + } + } + + // ─── Formatting shortcuts ──────────────────────────── + if (meta && !e.shiftKey && !e.altKey && e.key === 'b') { e.preventDefault(); pushHistory(true); wrapSelection('**', '**'); pushHistory(true); return; } + if (meta && !e.shiftKey && !e.altKey && e.key === 'i') { e.preventDefault(); pushHistory(true); wrapSelection('*', '*'); pushHistory(true); return; } + if (meta && e.shiftKey && e.key === 's') { e.preventDefault(); pushHistory(true); wrapSelection('~~', '~~'); pushHistory(true); return; } + if (meta && !e.shiftKey && !e.altKey && e.key === 'e') { e.preventDefault(); pushHistory(true); wrapSelection('`', '`'); pushHistory(true); return; } + if (meta && !e.shiftKey && !e.altKey && e.key === 'u') { e.preventDefault(); pushHistory(true); wrapSelection('', ''); pushHistory(true); return; } + if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) { e.preventDefault(); pushHistory(true); wrapSelection('$', '$'); pushHistory(true); return; } + + // ─── ⌘+Enter: toggle checkbox ─────────────────────── + if (meta && e.key === 'Enter') { + if (block.type === 'checkbox') { + e.preventDefault(); + pushHistory(true); + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, checked: !b.checked } : b)); + pushHistory(true); + return; + } + } + }, [slashMenu, focusBlock, undo, redo, pushHistory]); + + // Wrap selection with prefix/suffix + const wrapSelection = useCallback((prefix: string, suffix: string) => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + const range = sel.getRangeAt(0); + const selected = range.toString(); + if (!selected) return; + const text = prefix + selected + suffix; + range.deleteContents(); + range.insertNode(document.createTextNode(text)); + // Update block content + const el = range.startContainer.parentElement?.closest('[data-block-id]') as HTMLElement; + if (el) { + const blockId = el.dataset.blockId; + if (blockId) { + setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, content: el.textContent || '' } : b)); + } + } + }, []); + + // ─── Drag & Drop ───────────────────────────────────────────── + const handleDragStart = useCallback((e: React.DragEvent, idx: number) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + setDragIdx(idx); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, idx: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropIdx(idx); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, toIdx: number) => { + e.preventDefault(); + const fromIdx = dragIdx; + setDragIdx(null); + setDropIdx(null); + if (fromIdx === null || fromIdx === toIdx) return; + setBlocks(prev => { + const updated = [...prev]; + const [moved] = updated.splice(fromIdx, 1); + updated.splice(toIdx > fromIdx ? toIdx - 1 : toIdx, 0, moved); + return updated; + }); + }, [dragIdx]); + + const handleDragEnd = useCallback(() => { + setDragIdx(null); + setDropIdx(null); + }, []); + + // ─── Render ────────────────────────────────────────────────── + return ( +
+ {blocks.map((block, idx) => ( +
+ {/* Drop indicator */} + {dropIdx === idx && dragIdx !== null && dragIdx !== idx && ( +
+ )} +
handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + > + {/* Drag handle */} +
handleDragStart(e, idx)} + onDragEnd={handleDragEnd} + className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab opacity-0 group-hover:opacity-30 hover:!opacity-60 transition-opacity mt-0.5" + > + +
+ + {/* Block content */} + {block.type === 'divider' ? ( +
+
+
+ ) : block.type === 'math' ? ( + /* ─── Math Block ─── */ +
+ {focusedBlockId === block.id ? ( + /* Editing: raw LaTeX input */ +
{ + if (el) { + blockElsRef.current.set(block.id, el); + if (!el.dataset.init) { el.textContent = block.content; el.dataset.init = '1'; } + } else { blockElsRef.current.delete(block.id); } + }} + contentEditable={"plaintext-only" as any} + suppressContentEditableWarning + onInput={() => handleBlockInput(block.id)} + onKeyDown={(e) => handleKeyDown(e, block.id)} + onFocus={() => { setFocusedBlockId(block.id); if (slashMenu && slashMenu.blockId !== block.id) setSlashMenu(null); }} + onBlur={() => { if (focusedBlockId === block.id) setFocusedBlockId(null); }} + className="flex-1 outline-none min-h-[24px] leading-[1.65] text-[12px] font-mono text-[var(--text-secondary)] bg-[var(--input-bg)] rounded px-2 py-1 whitespace-pre" + data-placeholder="LaTeX equation (e.g. E = mc^2)" + style={{ '--placeholder-color': 'var(--text-disabled)' } as any} + /> + ) : ( + /* Preview: rendered KaTeX */ +
{ + setFocusedBlockId(block.id); + setTimeout(() => { + const el = blockElsRef.current.get(block.id); + if (el) { el.focus(); setCursorPosition(el, block.content.length); } + }, 0); + }} + className="flex-1 min-h-[24px] py-1 px-1 cursor-text rounded hover:bg-[var(--bg-secondary)]/30 transition-colors" + > + {block.content.trim() ? ( +
+ ) : ( + Empty equation — click to edit + )} +
+ )} +
+ ) : ( +
+ {/* Block type indicator */} + {block.type === 'checkbox' && ( + + )} + {block.type === 'bullet' && ( + + )} + {block.type === 'ordered' && ( + + {(() => { let n = 1; for (let j = idx - 1; j >= 0 && blocks[j].type === 'ordered'; j--) n++; return `${n}.`; })()} + + )} + {block.type === 'blockquote' && ( +
+ )} + + {/* Editable content — with inline math overlay when not focused */} +
+
{ + if (el) { + blockElsRef.current.set(block.id, el); + if (!el.dataset.init) { el.textContent = block.content; el.dataset.init = '1'; } + } else { blockElsRef.current.delete(block.id); } + }} + contentEditable={"plaintext-only" as any} + suppressContentEditableWarning + onInput={() => handleBlockInput(block.id)} + onKeyDown={(e) => handleKeyDown(e, block.id)} + onFocus={() => { setFocusedBlockId(block.id); if (slashMenu && slashMenu.blockId !== block.id) setSlashMenu(null); }} + onBlur={() => { if (focusedBlockId === block.id) setFocusedBlockId(null); }} + className={[ + 'outline-none min-h-[24px] leading-[1.65]', + block.type === 'h1' && 'text-[22px] font-bold text-[var(--text-primary)]', + block.type === 'h2' && 'text-[17px] font-semibold text-[var(--text-primary)]', + block.type === 'h3' && 'text-[14px] font-semibold text-[var(--text-primary)]', + block.type === 'paragraph' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'bullet' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'ordered' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'checkbox' && block.checked && 'text-[13px] text-[var(--text-subtle)] line-through', + block.type === 'checkbox' && !block.checked && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'blockquote' && 'text-[13px] text-[var(--text-muted)] italic', + block.type === 'code' && 'text-[12px] font-mono text-[var(--text-secondary)] bg-[var(--input-bg)] rounded px-2 py-1 whitespace-pre', + // Hide raw text when showing inline math overlay + focusedBlockId !== block.id && block.content.includes('$') && block.type !== 'code' && 'invisible', + ].filter(Boolean).join(' ')} + data-placeholder={focusedBlockId === block.id ? (block.type === 'h1' ? 'Heading 1' : block.type === 'h2' ? 'Heading 2' : block.type === 'h3' ? 'Heading 3' : block.type === 'paragraph' ? "Type '/' for commands..." : '') : ''} + style={{ '--placeholder-color': 'var(--text-disabled)' } as any} + /> + {/* Inline math rendered overlay — shown when block is not focused and has $ */} + {focusedBlockId !== block.id && block.content.includes('$') && block.type !== 'code' && ( +
{ + setFocusedBlockId(block.id); + const el = blockElsRef.current.get(block.id); + if (el) el.focus(); + }} + className={[ + 'absolute inset-0 cursor-text min-h-[24px] leading-[1.65]', + block.type === 'h1' && 'text-[22px] font-bold text-[var(--text-primary)]', + block.type === 'h2' && 'text-[17px] font-semibold text-[var(--text-primary)]', + block.type === 'h3' && 'text-[14px] font-semibold text-[var(--text-primary)]', + block.type === 'paragraph' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'bullet' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'ordered' && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'checkbox' && block.checked && 'text-[13px] text-[var(--text-subtle)] line-through', + block.type === 'checkbox' && !block.checked && 'text-[13px] text-[var(--text-secondary)]', + block.type === 'blockquote' && 'text-[13px] text-[var(--text-muted)] italic', + ].filter(Boolean).join(' ')} + dangerouslySetInnerHTML={{ __html: renderInlineMath(block.content.replace(/&/g, '&').replace(//g, '>')) }} + /> + )} +
+
+ )} +
+
+ ))} + + {/* Slash menu */} + {slashMenu && ( + setSlashMenu(null)} + /> + )} + + {/* CSS for placeholder */} + +
+ ); +}; + +// ─── Tooltip ───────────────────────────────────────────────────────── + +const ShortcutTooltip: React.FC<{ + label: string; + shortcut?: string[]; + visible: boolean; + position?: 'top' | 'bottom'; +}> = ({ label, shortcut, visible, position = 'top' }) => { + if (!visible) return null; + const posClass = position === 'top' + ? 'absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5' + : 'absolute top-full left-1/2 -translate-x-1/2 mt-1.5'; + return ( +
+
+ {label} + {shortcut?.map((k, i) => ( + + {k} + + ))} +
+
+ ); +}; + +// ─── Toolbar Button ────────────────────────────────────────────────── + +interface ToolbarBtnProps { + icon: React.FC; + label: string; + shortcut?: string[]; + onClick: () => void; + iconSize?: number; + className?: string; + tooltipPosition?: 'top' | 'bottom'; } -// ─── Editor View ──────────────────────────────────────────────────── +const ToolbarBtn: React.FC = ({ icon: Icon, label, shortcut, onClick, iconSize = 15, className, tooltipPosition = 'top' }) => { + const [hover, setHover] = useState(false); + return ( +
+ + +
+ ); +}; + +// ─── Heading Dropdown Button ───────────────────────────────────────── + +const HEADING_OPTIONS = [ + { label: 'Heading 1', prefix: '# ', keys: ['⌥', '⌘', '1'], size: 'text-[16px]' }, + { label: 'Heading 2', prefix: '## ', keys: ['⌥', '⌘', '2'], size: 'text-[14px]' }, + { label: 'Heading 3', prefix: '### ', keys: ['⌥', '⌘', '3'], size: 'text-[13px]' }, +]; + +const HeadingDropdownBtn: React.FC<{ + showMenu: boolean; + onToggle: () => void; + onSelect: (prefix: string) => void; +}> = ({ showMenu, onToggle, onSelect }) => { + const [hover, setHover] = useState(false); + return ( +
+ + {!showMenu && } + {showMenu && ( +
+ {HEADING_OPTIONS.map((h) => ( + + ))} +
+ )} +
+ ); +}; + +// ─── Editor View ───────────────────────────────────────────────────── interface EditorViewProps { note: Note | null; @@ -247,32 +1108,24 @@ const EditorView: React.FC = ({ const [theme, setTheme] = useState(note?.theme || 'default'); const [showToolbar, setShowToolbar] = useState(false); const [findQuery, setFindQuery] = useState(''); + const [showHeadingMenu, setShowHeadingMenu] = useState(false); const [manualResize, setManualResize] = useState(false); const [showAutoSizeBtn, setShowAutoSizeBtn] = useState(false); - const contentRef = useRef(null); - const measureRef = useRef(null); + const findInputRef = useRef(null); const saveTimeoutRef = useRef | null>(null); + const measureRef = useRef(null); const autoSizeTimeoutRef = useRef | null>(null); - const findInputRef = useRef(null); - // Derive title from content (Raycast behavior: first line = title) + const title = useMemo(() => extractTitleFromContent(content), [content]); + const accentColor = THEME_ACCENT[theme]; - // Sync state when note prop changes + // Sync state when note changes useEffect(() => { setIcon(note?.icon || ''); setContent(note?.content || ''); setTheme(note?.theme || 'default'); }, [note?.id]); - // Focus content area on mount - useEffect(() => { - setTimeout(() => { - const el = contentRef.current; - if (el) { el.focus(); el.setSelectionRange(el.value.length, el.value.length); } - }, 50); - }, [note?.id]); - - // Focus find input when opened useEffect(() => { if (showFind) setTimeout(() => findInputRef.current?.focus(), 50); }, [showFind]); @@ -287,12 +1140,8 @@ const EditorView: React.FC = ({ return () => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); }; }, [title, icon, content, theme, note]); - // ─── Dynamic window auto-sizing ────────────────────────────── - // Measures content via a hidden div and resizes the Electron window. - // Skipped when user has manually resized (manualResize). - + // Dynamic window auto-sizing useEffect(() => { - // Check manual resize state from main process window.electron.noteGetManualResize().then(setManualResize); }, []); @@ -302,10 +1151,9 @@ const EditorView: React.FC = ({ autoSizeTimeoutRef.current = setTimeout(() => { const measure = measureRef.current; if (!measure) return; - // Title bar ~40px, find bar ~36px if shown, toolbar ~40px if shown, bottom bar ~32px, padding 32px - const chrome = 40 + (showFind ? 36 : 0) + (showToolbar ? 40 : 0) + 32 + 32; + const chrome = 40 + (showFind ? 36 : 0) + (showToolbar ? 40 : 0) + 36 + 32; const contentHeight = measure.scrollHeight; - const desiredHeight = Math.max(420, chrome + contentHeight); // min 420px for vertical feel + const desiredHeight = Math.max(420, chrome + contentHeight); window.electron.noteSetWindowHeight(desiredHeight); }, 150); return () => { if (autoSizeTimeoutRef.current) clearTimeout(autoSizeTimeoutRef.current); }; @@ -317,36 +1165,6 @@ const EditorView: React.FC = ({ setShowAutoSizeBtn(false); }, []); - // Markdown insertion helpers - const insertMarkdown = useCallback((prefix: string, suffix: string = '') => { - const el = contentRef.current; - if (!el) return; - const start = el.selectionStart; - const end = el.selectionEnd; - const selected = content.slice(start, end); - const replacement = `${prefix}${selected || 'text'}${suffix}`; - const newContent = content.slice(0, start) + replacement + content.slice(end); - setContent(newContent); - requestAnimationFrame(() => { - el.focus(); - const cursorPos = start + prefix.length; - el.setSelectionRange(cursorPos, cursorPos + (selected || 'text').length); - }); - }, [content]); - - const insertLinePrefix = useCallback((prefix: string) => { - const el = contentRef.current; - if (!el) return; - const start = el.selectionStart; - const lineStart = content.lastIndexOf('\n', start - 1) + 1; - const newContent = content.slice(0, lineStart) + prefix + content.slice(lineStart); - setContent(newContent); - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(start + prefix.length, start + prefix.length); - }); - }, [content]); - // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -355,12 +1173,11 @@ const EditorView: React.FC = ({ const alt = e.altKey; if (e.key === 'Escape') { - if (showFind) { setShowFind(false); e.preventDefault(); contentRef.current?.focus(); return; } + if (showFind) { setShowFind(false); e.preventDefault(); return; } if (showToolbar) { setShowToolbar(false); e.preventDefault(); return; } if (!note && content.trim()) onSave({ title: title || 'Untitled', icon, content, theme }); e.preventDefault(); onClose(); return; } - if (meta && !shift && !alt && e.key === 'n') { e.preventDefault(); onNewNote(); return; } if (meta && !shift && !alt && e.key === 'k') { e.preventDefault(); onShowActions(); return; } if (meta && !shift && !alt && e.key === 'p') { e.preventDefault(); onBrowse(); return; } @@ -370,259 +1187,114 @@ const EditorView: React.FC = ({ if (meta && !shift && !alt && e.key === '[') { e.preventDefault(); onNavigateBack(); return; } if (meta && !shift && !alt && e.key === ']') { e.preventDefault(); onNavigateForward(); return; } if (meta && alt && e.key === ',') { e.preventDefault(); setShowToolbar(p => !p); return; } - // ⇧⌘, — Format submenu (show format bar as well) if (meta && shift && !alt && e.key === ',') { e.preventDefault(); setShowToolbar(p => !p); return; } - - // Paragraph formatting - if (meta && alt && e.key === '1') { e.preventDefault(); insertLinePrefix('# '); return; } - if (meta && alt && e.key === '2') { e.preventDefault(); insertLinePrefix('## '); return; } - if (meta && alt && e.key === '3') { e.preventDefault(); insertLinePrefix('### '); return; } - if (meta && alt && e.key === 'c') { e.preventDefault(); insertMarkdown('\n```\n', '\n```\n'); return; } - if (meta && shift && e.key === 'b') { e.preventDefault(); insertLinePrefix('> '); return; } - if (meta && shift && e.key === '7') { e.preventDefault(); insertLinePrefix('1. '); return; } - if (meta && shift && e.key === '8') { e.preventDefault(); insertLinePrefix('- '); return; } - if (meta && shift && e.key === '9') { e.preventDefault(); insertLinePrefix('- [ ] '); return; } - - // Inline formatting - if (meta && !shift && !alt && e.key === 'b') { e.preventDefault(); insertMarkdown('**', '**'); return; } - if (meta && !shift && !alt && e.key === 'i') { e.preventDefault(); insertMarkdown('*', '*'); return; } - if (meta && shift && e.key === 's') { e.preventDefault(); insertMarkdown('~~', '~~'); return; } - if (meta && !shift && !alt && e.key === 'u') { e.preventDefault(); insertMarkdown('', ''); return; } - if (meta && !shift && !alt && e.key === 'e') { e.preventDefault(); insertMarkdown('`', '`'); return; } - if (meta && !shift && !alt && e.key === 'l') { e.preventDefault(); insertMarkdown('[', '](url)'); return; } - - // ⌘+Enter — toggle checkbox on current line - if (meta && !shift && !alt && e.key === 'Enter') { - const el = contentRef.current; - if (el) { - const pos = el.selectionStart; - const lineStart = content.lastIndexOf('\n', pos - 1) + 1; - const lineEnd = content.indexOf('\n', pos); - const line = content.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); - if (line.match(/^(\s*)- \[ \]/)) { - e.preventDefault(); - const newLine = line.replace('- [ ]', '- [x]'); - const newContent = content.slice(0, lineStart) + newLine + (lineEnd === -1 ? '' : content.slice(lineEnd)); - setContent(newContent); - requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); - return; - } - if (line.match(/^(\s*)- \[x\]/)) { - e.preventDefault(); - const newLine = line.replace('- [x]', '- [ ]'); - const newContent = content.slice(0, lineStart) + newLine + (lineEnd === -1 ? '' : content.slice(lineEnd)); - setContent(newContent); - requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); - return; - } - } - } - - // ⇧⌘E — Export - // handled in main component }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [showToolbar, showFind, note, title, icon, content, theme, onClose, onNewNote, onBrowse, onShowActions, onNavigateBack, onNavigateForward, onDuplicate, onTogglePin, insertMarkdown, insertLinePrefix, setShowFind]); + }, [showToolbar, showFind, note, title, icon, content, theme, onClose, onNewNote, onBrowse, onShowActions, onNavigateBack, onNavigateForward, onDuplicate, onTogglePin, setShowFind]); - // Heading dropdown state - const [showHeadingMenu, setShowHeadingMenu] = useState(false); + // Markdown insertion helpers (for format toolbar) + const insertMarkdownIntoContent = useCallback((prefix: string, suffix: string = '') => { + // These work by appending to content - used by format bar buttons + setContent(prev => prev + prefix + 'text' + suffix); + }, []); + + const insertLinePrefixIntoContent = useCallback((prefix: string) => { + setContent(prev => prev + '\n' + prefix); + }, []); return (
- {/* Title bar — glass-effect with subtle tint */} -
-
- {icon && {icon}} - {title} + {/* Title bar */} +
+ +
+ {icon && {icon}} + {title}
- - - + +
{/* Find bar */} {showFind && ( -
- +
+ setFindQuery(e.target.value)} placeholder="Find in note..." - className="flex-1 bg-transparent text-white/80 text-[13px] placeholder-white/25 outline-none" - onKeyDown={(e) => { - if (e.key === 'Escape') { setShowFind(false); contentRef.current?.focus(); e.stopPropagation(); } - }} + className="flex-1 bg-transparent text-[var(--text-primary)] text-[13px] placeholder:text-[var(--text-disabled)] outline-none" + onKeyDown={(e) => { if (e.key === 'Escape') { setShowFind(false); e.stopPropagation(); } }} /> -
)} {/* Hidden measure div for auto-sizing */} -
+
{content || 'X'}
- {/* Content area — always textarea */} -
-