diff --git a/electron/main.ts b/electron/main.ts index d452e6a..816cb5b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -596,7 +596,7 @@ ipcMain.on('open-settings', () => { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, - webSecurity: false, + webSecurity: true, }, }) @@ -614,6 +614,24 @@ ipcMain.on('open-settings', () => { }) }) + +ipcMain.handle('openai-chat', async (_, { model, messages, apiKey, baseURL }) => { + try { + const OpenAI = (await import('openai')).default + const openai = new OpenAI({ + apiKey: apiKey || 'dummy', + baseURL: baseURL || undefined, + }) + const completion = await openai.chat.completions.create({ + model: model, + messages: messages, + }) + return completion + } catch (error: any) { + throw new Error(error.message || 'Unknown API Error') + } +}) + ipcMain.on('quit-app', () => { app.quit() }) diff --git a/electron/preload.ts b/electron/preload.ts index 2feec57..9a5c11c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', { saveNote: (id: string, content: string) => ipcRenderer.invoke('save-note', { id, content }), deleteNote: (id: string) => ipcRenderer.invoke('delete-note', id), renameNote: (oldId: string, newId: string) => ipcRenderer.invoke('rename-note', { oldId, newId }), + openAIChat: (args: { model: string, messages: { role: string; content: string }[], apiKey: string, baseURL: string }) => ipcRenderer.invoke('openai-chat', args), readNote: (id: string) => ipcRenderer.invoke('read-note', id), exportNote: (filename: string, content: string) => ipcRenderer.invoke('export-note', filename, content), diff --git a/src/App.tsx b/src/App.tsx index e698618..30e4311 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { search } from '@codemirror/search' import { insertTab, indentLess } from '@codemirror/commands' import './App.css' -import { getFolderColor } from './utils' import GraphView from './GraphView' import { RemindersPage } from './components/RemindersPage' @@ -27,14 +26,14 @@ import { symbolPlugin, aiPlugin, mathPlugin, - hideMarkdownPlugin, - remConverterPlugin, + decomposedPlugins, } from './lib/editor/plugins' +import { NoteSearch } from './components/NoteSearch' +import { MainActionMenu } from './components/MainActionMenu' +import { NoteTitleBar } from './components/NoteTitleBar' import { getSecure } from './lib/safeStorage' -let openaiInstance: any = null -let currentApiKey = '' -let currentApiBaseUrl = '' +import { MathEvaluator } from './lib/editor/MathEvaluator' function App() { const { @@ -47,22 +46,8 @@ function App() { setShowGraphView, showRemindersView, setShowRemindersView, - isRenaming, - setIsRenaming, - renameValue, - setRenameValue, showNoteSearch, - setShowNoteSearch, - noteSearchQuery, - setNoteSearchQuery, - searchSelectedIndex, - setSearchSelectedIndex, - showNoteActionMenu, - setShowNoteActionMenu, - showMainActionMenu, setShowMainActionMenu, - actionMenuIndex, - setActionMenuIndex, } = useAppStore() const { @@ -92,13 +77,11 @@ function App() { fetchApiKey() }, [setApiKey]) - const editorRef = useRef(null) - const mathCalcTimeoutRef = useRef(null) - const searchInputRef = useRef(null) + const editorRef = useRef<{ view?: EditorView } | null>(null) - useEffect(() => {}, [notes]) + const searchInputRef = useRef(null) - useEffect(() => {}, [currentNoteIndex]) + const activeNote = notes[currentNoteIndex] || { id: '', content: '', mtime: 0 } // Custom Hooks useNoteStorage() @@ -147,35 +130,10 @@ function App() { }) } window.addEventListener('storage', handleStorageChange) - return () => window.removeEventListener('storage', handleStorageChange) - }, [setApiKey]) - - const activeNote = notes[currentNoteIndex] || { id: '', content: '' } - const isAuto = /^\d+\.md$/.test(activeNote.id) - const pathParts = activeNote.id.replace(/\.md$/, '').split('/') - const fileName = pathParts.pop() || '' - const displayTitle = isAuto ? activeNote.content.split('\n')[0].trim() || 'New Note' : fileName - - const startRename = () => { - setRenameValue(activeNote.id.replace(/\.md$/, '')) - setIsRenaming(true) - } - - const handleRenameSubmit = () => { - setIsRenaming(false) - if (renameValue && renameValue.trim() && renameValue !== displayTitle) { - const newId = renameValue.trim() + '.md' - window.electronAPI.renameNote(activeNote.id, newId) - const updatedNotes = [...notes] - updatedNotes[currentNoteIndex].id = newId - setNotes(updatedNotes) + return () => { + window.removeEventListener('storage', handleStorageChange) } - } - - const handleRenameKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') handleRenameSubmit() - if (e.key === 'Escape') setIsRenaming(false) - } + }, [setApiKey]) const handleEditorChange = useCallback( (val: string, viewUpdate?: ViewUpdate) => { @@ -187,84 +145,9 @@ function App() { } if (viewUpdate?.transactions?.some((tr) => tr.docChanged)) { - if (mathCalcTimeoutRef.current) { - clearTimeout(mathCalcTimeoutRef.current) + if (editorRef.current?.view) { + MathEvaluator.triggerMathEvaluation(editorRef.current.view) } - mathCalcTimeoutRef.current = window.setTimeout(async () => { - if (!editorRef.current?.view) return - const view = editorRef.current.view - const docStr = view.state.doc.toString() - const head = view.state.selection.main.head - const line = view.state.doc.lineAt(head) - - let mathjs: any - try { - mathjs = await import('mathjs') - } catch { - return - } - - const scope: Record = Object.assign( - {}, - (window as unknown as { __globalVariables: Record }) - .__globalVariables || {} - ) - const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm - let match - while ((match = reVar.exec(docStr)) !== null) { - const name = match[1] - try { - const val = mathjs.evaluate(match[2], scope) - scope[name] = val - } catch { - scope[name] = match[2].trim() - } - } - - const changes: { from: number; to: number; insert: string }[] = [] - - if (line.text.endsWith('=')) { - try { - const expr = line.text.substring(0, line.text.length - 1).trim() - if (expr) { - const result = String(mathjs.evaluate(expr, scope)) - changes.push({ - from: line.to, - to: line.to, - insert: '\u200B' + result, - }) - } - } catch {} - } - - const reCalc = /^(.*?=\s*)\u200B(.*)$/gm - let calcMatch - while ((calcMatch = reCalc.exec(docStr)) !== null) { - const exprPart = calcMatch[1] - const oldResult = calcMatch[2] - const expr = exprPart.replace(/=$/, '').trim() - if (expr) { - try { - const newResult = String(mathjs.evaluate(expr, scope)) - if (newResult !== oldResult) { - const startReplace = calcMatch.index + exprPart.length + 1 // +1 for \u200B - const endReplace = calcMatch.index + calcMatch[0].length - if (!changes.some((c) => c.from <= endReplace && c.to >= startReplace)) { - changes.push({ - from: startReplace, - to: endReplace, - insert: newResult, - }) - } - } - } catch {} - } - } - - if (changes.length > 0) { - view.dispatch({ changes }) - } - }, 300) } }, [notes, currentNoteIndex, activeNote.id, setNotes] @@ -409,27 +292,8 @@ function App() { finalBaseUrl = finalBaseUrl.slice(0, -1) } - if ( - !openaiInstance || - currentApiKey !== apiKey || - currentApiBaseUrl !== finalBaseUrl - ) { - const OpenAI = (await import('openai')).default - openaiInstance = new OpenAI({ - apiKey: apiKey.trim() || 'dummy', - baseURL: finalBaseUrl || undefined, - dangerouslyAllowBrowser: true, - defaultHeaders: { - 'HTTP-Referer': 'https://github.com/papercache/papercache', - 'X-Title': 'PaperCache', - }, - }) - currentApiKey = apiKey - currentApiBaseUrl = finalBaseUrl - } - const systemContent = aiSystemPrompt.trim() - const messages: { role: string; content: string }[] = [] + const messages: { role: 'user' | 'system'; content: string }[] = [] if (systemContent) { messages.push({ role: 'system', content: systemContent }) } @@ -449,12 +313,15 @@ function App() { messages.push({ role: 'user', content: finalPrompt }) - openaiInstance.chat.completions - .create({ + window.electronAPI + .openAIChat({ model: apiModel.trim() || 'nvidia/nemotron-3-super-120b-a12b:free', messages: messages, + apiKey: apiKey.trim() || '', + baseURL: finalBaseUrl || '', }) - .then((completion: Record) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .then((completion: any) => { let response: string if (completion.choices && completion.choices.length > 0) { response = completion.choices[0].message?.content || '' @@ -507,8 +374,7 @@ function App() { symbolPlugin, aiPlugin, mathPlugin, - hideMarkdownPlugin, - remConverterPlugin, + ...decomposedPlugins, EditorView.domEventHandlers({ mousedown: (event) => { const target = event.target as HTMLElement @@ -572,30 +438,7 @@ function App() { style={containerStyle} onClick={handleAppClick} > -
- {isRenaming ? ( - setRenameValue(e.target.value)} - onBlur={handleRenameSubmit} - onKeyDown={handleRenameKeyDown} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - { - e.stopPropagation() - startRename() - }} - title="Click to rename" - > - {displayTitle} - - )} -
+ {showRemindersView && ( )} - {showNoteSearch && - (() => { - const filteredNotes = notes.filter( - (n) => - n.content.toLowerCase().includes(noteSearchQuery.toLowerCase()) || - n.id.toLowerCase().includes(noteSearchQuery.toLowerCase()) - ) - - const allTags = new Set() - notes.forEach((n) => { - const matches = n.content.match(/![a-zA-Z0-9_-]+/g) - if (matches) { - matches.forEach((m) => allTags.add(m.toLowerCase())) - } - }) - const tagArray = Array.from(allTags).sort() - - return ( -
{ - setShowNoteSearch(false) - setShowNoteActionMenu(false) - }} - onKeyDown={(e) => e.stopPropagation()} - > -
e.stopPropagation()} - onKeyDown={(e) => { - if (showNoteActionMenu) { - if (e.key === 'ArrowDown') { - e.preventDefault() - setActionMenuIndex((prev) => Math.min(prev + 1, 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setActionMenuIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - const selNote = filteredNotes[searchSelectedIndex] - if (!selNote) return - if (actionMenuIndex === 0) { - if (selNote.id.startsWith('commands/')) { - alert('Files in the commands folder cannot be deleted.') - setShowNoteActionMenu(false) - return - } - if (confirm('Delete this note?')) { - window.electronAPI.deleteNote(selNote.id) - setNotes((prev) => prev.filter((note) => note.id !== selNote.id)) - if (currentNoteIndex >= notes.length - 1) - setCurrentNoteIndex(Math.max(0, notes.length - 2)) - setShowNoteSearch(false) - setShowNoteActionMenu(false) - } - } else if (actionMenuIndex === 1) { - const blob = new Blob([selNote.content], { type: 'text/markdown' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = selNote.id.split('/').pop() || selNote.id - a.click() - URL.revokeObjectURL(url) - setShowNoteActionMenu(false) - } - } else if (e.key === 'Escape') { - e.preventDefault() - setShowNoteActionMenu(false) - } else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - setShowNoteActionMenu(false) - } - return - } + - if (e.key === 'ArrowDown') { - e.preventDefault() - setSearchSelectedIndex((prev) => Math.min(prev + 1, filteredNotes.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setSearchSelectedIndex((prev) => Math.max(prev - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (showNoteActionMenu) return - if (filteredNotes.length > 0) { - const selNote = filteredNotes[searchSelectedIndex] - const idx = notes.findIndex((note) => note.id === selNote.id) - if (idx !== -1) setCurrentNoteIndex(idx) - setShowNoteSearch(false) - } - } else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { - e.preventDefault() - if (filteredNotes.length > 0) { - setShowNoteActionMenu(true) - setActionMenuIndex(0) - } - } else if (e.key === 'Escape') { - e.preventDefault() - setShowNoteSearch(false) - } - }} - > - { - setNoteSearchQuery(e.target.value) - setSearchSelectedIndex(0) - setShowNoteActionMenu(false) - }} - /> - {tagArray.length > 0 && ( -
- {tagArray.map((tag) => ( - { - e.stopPropagation() - const newQ = noteSearchQuery ? noteSearchQuery + ' ' + tag : tag - setNoteSearchQuery(newQ) - searchInputRef.current?.focus() - }} - > - {tag} - - ))} -
- )} -
- {filteredNotes.map((n, index) => { - const isAuto = /^\d+\.md$/.test(n.id) - const pathParts = n.id.replace(/\.md$/, '').split('/') - const fileName = pathParts.pop() || '' - const title = isAuto ? n.content.split('\n')[0].trim() || 'New Note' : fileName - const isSelected = index === searchSelectedIndex - return ( -
setSearchSelectedIndex(index)} - onClick={() => { - const idx = notes.findIndex((note) => note.id === n.id) - if (idx !== -1) setCurrentNoteIndex(idx) - setShowNoteSearch(false) - }} - onContextMenu={(e) => { - e.preventDefault() - setSearchSelectedIndex(index) - setShowNoteActionMenu(true) - setActionMenuIndex(0) - }} - > -
- {title} - {pathParts.length > 0 && ( -
-
- {pathParts.join(' / ')} -
- )} -
- {new Date(n.mtime).toLocaleDateString()} - - {isSelected && showNoteActionMenu && ( -
e.stopPropagation()}> - - -
- )} -
- ) - })} -
-
-
- ) - })()} - - {showMainActionMenu && ( -
- - - - - -
- )} + {showGraphView && ( window.removeEventListener('keydown', handleKeyDown) }, []) const [apiBaseUrl, setApiBaseUrl] = useState( - localStorage.getItem('papercache-api-base-url') || 'https://openrouter.ai/api/v1' + localStorage.getItem(SETTINGS_KEYS.API_BASE_URL) || 'https://openrouter.ai/api/v1' ) const [apiModel, setApiModel] = useState( - localStorage.getItem('papercache-api-model') || 'nvidia/nemotron-3-super-120b-a12b:free' + localStorage.getItem(SETTINGS_KEYS.API_MODEL) || 'nvidia/nemotron-3-super-120b-a12b:free' ) const [aiSystemPrompt, setAiSystemPrompt] = useState( - localStorage.getItem('papercache-ai-system-prompt') || + localStorage.getItem(SETTINGS_KEYS.AI_SYSTEM_PROMPT) || 'Please provide a short and concise answer.' ) // Shortcuts const [globalShortcutNewNote, setGlobalShortcutNewNote] = useState( - localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N' + localStorage.getItem(SETTINGS_KEYS.SHORTCUT_NEWNOTE) || 'CommandOrControl+Shift+N' ) const [globalShortcutToggle, setGlobalShortcutToggle] = useState( - localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C' + localStorage.getItem(SETTINGS_KEYS.SHORTCUT_TOGGLE) || 'CommandOrControl+Shift+C' ) // Startup const [launchAtStartup, setLaunchAtStartup] = useState( - localStorage.getItem('papercache-launch-startup') === 'true' + localStorage.getItem(SETTINGS_KEYS.LAUNCH_STARTUP) === 'true' ) // Appearance State const [fontFamily, setFontFamily] = useState( - localStorage.getItem('papercache-font') || "'JetBrains Mono', monospace" + localStorage.getItem(SETTINGS_KEYS.FONT_FAMILY) || "'JetBrains Mono', monospace" ) const [showRulings, setShowRulings] = useState( - localStorage.getItem('papercache-show-rulings') === 'true' + localStorage.getItem(SETTINGS_KEYS.SHOW_RULINGS) !== 'false' ) const [themePreset, setThemePreset] = useState( - localStorage.getItem('papercache-theme') || 'paper-light' + localStorage.getItem(SETTINGS_KEYS.THEME_PRESET) || 'grid-light' ) - const [bgType, setBgType] = useState(localStorage.getItem('papercache-bg-type') || 'preset') // preset, color, image - const [bgColor, setBgColor] = useState(localStorage.getItem('papercache-bg-color') || '#ffffff') - const [bgImage, setBgImage] = useState(localStorage.getItem('papercache-bg-image') || '') + const [bgType, setBgType] = useState(localStorage.getItem(SETTINGS_KEYS.BG_TYPE) || 'color') // preset, color, image + const [bgColor, setBgColor] = useState(localStorage.getItem(SETTINGS_KEYS.BG_COLOR) || '#ffffff') + const [bgImage, setBgImage] = useState(localStorage.getItem(SETTINGS_KEYS.BG_IMAGE) || '') const [textColor, setTextColor] = useState( - localStorage.getItem('papercache-color-text') || '#333333' + localStorage.getItem(SETTINGS_KEYS.TEXT_COLOR) || '#000000' ) const [numColor, setNumColor] = useState( - localStorage.getItem('papercache-color-num') || '#8ab4f8' + localStorage.getItem(SETTINGS_KEYS.NUM_COLOR) || '#8ab4f8' ) const [symColor, setSymColor] = useState( - localStorage.getItem('papercache-color-sym') || '#c586c0' + localStorage.getItem(SETTINGS_KEYS.SYM_COLOR) || '#ff0000' ) - const [aiColor, setAiColor] = useState(localStorage.getItem('papercache-color-ai') || '#10b981') + const [aiColor, setAiColor] = useState(localStorage.getItem(SETTINGS_KEYS.AI_COLOR) || '#8b5cf6') const [mathColor, setMathColor] = useState( - localStorage.getItem('papercache-color-math') || '#f59e0b' + localStorage.getItem(SETTINGS_KEYS.MATH_COLOR) || '#10b981' ) const saveSettings = async () => { await setSecure('papercache-apikey', apiKey) localStorage.removeItem('papercache-apikey') - localStorage.setItem('papercache-api-base-url', apiBaseUrl) - localStorage.setItem('papercache-api-model', apiModel) - localStorage.setItem('papercache-ai-system-prompt', aiSystemPrompt) - - localStorage.setItem('papercache-font', fontFamily) - localStorage.setItem('papercache-show-rulings', showRulings.toString()) - localStorage.setItem('papercache-theme', themePreset) - localStorage.setItem('papercache-bg-type', bgType) - localStorage.setItem('papercache-bg-color', bgColor) - localStorage.setItem('papercache-bg-image', bgImage) - - localStorage.setItem('papercache-color-text', textColor) - localStorage.setItem('papercache-color-num', numColor) - localStorage.setItem('papercache-color-sym', symColor) - localStorage.setItem('papercache-color-ai', aiColor) - localStorage.setItem('papercache-color-math', mathColor) + localStorage.setItem(SETTINGS_KEYS.API_BASE_URL, apiBaseUrl) + localStorage.setItem(SETTINGS_KEYS.API_MODEL, apiModel) + localStorage.setItem(SETTINGS_KEYS.AI_SYSTEM_PROMPT, aiSystemPrompt) + + localStorage.setItem(SETTINGS_KEYS.FONT_FAMILY, fontFamily) + localStorage.setItem(SETTINGS_KEYS.SHOW_RULINGS, showRulings.toString()) + localStorage.setItem(SETTINGS_KEYS.THEME_PRESET, themePreset) + localStorage.setItem(SETTINGS_KEYS.BG_TYPE, bgType) + localStorage.setItem(SETTINGS_KEYS.BG_COLOR, bgColor) + localStorage.setItem(SETTINGS_KEYS.BG_IMAGE, bgImage) + + localStorage.setItem(SETTINGS_KEYS.TEXT_COLOR, textColor) + localStorage.setItem(SETTINGS_KEYS.NUM_COLOR, numColor) + localStorage.setItem(SETTINGS_KEYS.SYM_COLOR, symColor) + localStorage.setItem(SETTINGS_KEYS.AI_COLOR, aiColor) + localStorage.setItem(SETTINGS_KEYS.MATH_COLOR, mathColor) // Startup - localStorage.setItem('papercache-launch-startup', launchAtStartup.toString()) + localStorage.setItem(SETTINGS_KEYS.LAUNCH_STARTUP, launchAtStartup.toString()) if (window.electronAPI.setLaunchAtStartup) { window.electronAPI.setLaunchAtStartup(launchAtStartup) } diff --git a/src/components/MainActionMenu.tsx b/src/components/MainActionMenu.tsx new file mode 100644 index 0000000..f1131b7 --- /dev/null +++ b/src/components/MainActionMenu.tsx @@ -0,0 +1,103 @@ +import { useAppStore } from '../store/useAppStore' +import { useSettingsStore } from '../store/useSettingsStore' + +export function MainActionMenu() { + const { + notes, + currentNoteIndex, + showMainActionMenu, + setShowNoteSearch, + setShowGraphView, + setShowRemindersView, + } = useAppStore() + + const { bgType, bgColor, textColor, fontFamily } = useSettingsStore() + + if (!showMainActionMenu) return null + + return ( +
+ + + + + +
+ ) +} diff --git a/src/components/NoteSearch.tsx b/src/components/NoteSearch.tsx new file mode 100644 index 0000000..af0e56d --- /dev/null +++ b/src/components/NoteSearch.tsx @@ -0,0 +1,266 @@ +import { useAppStore } from '../store/useAppStore' +import { getFolderColor } from '../utils' + +export function NoteSearch() { + const { + notes, + setNotes, + currentNoteIndex, + setCurrentNoteIndex, + showNoteSearch, + setShowNoteSearch, + noteSearchQuery, + setNoteSearchQuery, + searchSelectedIndex, + setSearchSelectedIndex, + showNoteActionMenu, + setShowNoteActionMenu, + actionMenuIndex, + setActionMenuIndex, + } = useAppStore() + + if (!showNoteSearch) return null + + const filteredNotes = notes.filter( + (n) => + n.content.toLowerCase().includes(noteSearchQuery.toLowerCase()) || + n.id.toLowerCase().includes(noteSearchQuery.toLowerCase()) + ) + + const allTags = new Set() + notes.forEach((n) => { + const matches = n.content.match(/![a-zA-Z0-9_-]+/g) + if (matches) { + matches.forEach((m) => allTags.add(m.toLowerCase())) + } + }) + const tagArray = Array.from(allTags).sort() + + return ( +
{ + setShowNoteSearch(false) + setShowNoteActionMenu(false) + }} + onKeyDown={(e) => e.stopPropagation()} + > +
e.stopPropagation()} + onKeyDown={(e) => { + if (showNoteActionMenu) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActionMenuIndex((prev) => Math.min(prev + 1, 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActionMenuIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const selNote = filteredNotes[searchSelectedIndex] + if (!selNote) return + if (actionMenuIndex === 0) { + if (selNote.id.startsWith('commands/')) { + alert('Files in the commands folder cannot be deleted.') + setShowNoteActionMenu(false) + return + } + if (confirm('Delete this note?')) { + window.electronAPI.deleteNote(selNote.id).then((success) => { + if (success) { + setNotes((prev) => prev.filter((note) => note.id !== selNote.id)) + const selIdx = notes.findIndex((n) => n.id === selNote.id) + const newIdx = + currentNoteIndex >= selIdx && currentNoteIndex > 0 + ? currentNoteIndex - 1 + : currentNoteIndex + setCurrentNoteIndex(newIdx) + setShowNoteSearch(false) + setShowNoteActionMenu(false) + } + }) + } + } else if (actionMenuIndex === 1) { + const blob = new Blob([selNote.content], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = selNote.id.split('/').pop() || selNote.id + a.click() + URL.revokeObjectURL(url) + setShowNoteActionMenu(false) + } + } else if (e.key === 'Escape') { + e.preventDefault() + setShowNoteActionMenu(false) + } else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setShowNoteActionMenu(false) + } + return + } + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSearchSelectedIndex((prev) => Math.min(prev + 1, filteredNotes.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSearchSelectedIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (showNoteActionMenu) return + if (filteredNotes.length > 0) { + const selNote = filteredNotes[searchSelectedIndex] + const idx = notes.findIndex((note) => note.id === selNote.id) + if (idx !== -1) setCurrentNoteIndex(idx) + setShowNoteSearch(false) + } + } else if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + if (filteredNotes.length > 0) { + setShowNoteActionMenu(true) + setActionMenuIndex(0) + } + } else if (e.key === 'Escape') { + e.preventDefault() + setShowNoteSearch(false) + } + }} + > + { + setNoteSearchQuery(e.target.value) + setSearchSelectedIndex(0) + setShowNoteActionMenu(false) + }} + /> + {tagArray.length > 0 && ( +
+ {tagArray.map((tag) => ( + { + e.stopPropagation() + const newQ = noteSearchQuery ? noteSearchQuery + ' ' + tag : tag + setNoteSearchQuery(newQ) + setSearchSelectedIndex(0) + }} + > + {tag} + + ))} +
+ )} +
+ {filteredNotes.map((n, index) => { + const isAuto = /^\d+\.md$/.test(n.id) + const pathParts = n.id.replace(/\.md$/, '').split('/') + const fileName = pathParts.pop() || '' + const title = isAuto ? n.content.split('\n')[0].trim() || 'New Note' : fileName + const isSelected = index === searchSelectedIndex + return ( +
setSearchSelectedIndex(index)} + onClick={() => { + const idx = notes.findIndex((note) => note.id === n.id) + if (idx !== -1) setCurrentNoteIndex(idx) + setShowNoteSearch(false) + }} + onContextMenu={(e) => { + e.preventDefault() + setSearchSelectedIndex(index) + setShowNoteActionMenu(true) + setActionMenuIndex(0) + }} + > +
+ {title} + {pathParts.length > 0 && ( +
+
+ {pathParts.join(' / ')} +
+ )} +
+ {new Date(n.mtime).toLocaleDateString()} + + {isSelected && showNoteActionMenu && ( +
e.stopPropagation()}> + + +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/NoteTitleBar.tsx b/src/components/NoteTitleBar.tsx new file mode 100644 index 0000000..ce32e6c --- /dev/null +++ b/src/components/NoteTitleBar.tsx @@ -0,0 +1,91 @@ +import { useAppStore } from '../store/useAppStore' + +export function NoteTitleBar() { + const { + notes, + setNotes, + currentNoteIndex, + isRenaming, + setIsRenaming, + renameValue, + setRenameValue, + } = useAppStore() + + const activeNote = notes[currentNoteIndex] || { id: 'note.md', content: '', mtime: 0 } + + const isAutoNamed = /^\d+\.md$/.test(activeNote.id) + const displayTitle = isAutoNamed + ? activeNote.content.split('\n')[0].trim() || 'New Note' + : activeNote.id.split('/').pop() || '' + + const startRename = () => { + setRenameValue(displayTitle) + setIsRenaming(true) + } + + const handleRenameSubmit = async () => { + setIsRenaming(false) + const newName = renameValue.trim() + if (!newName) return + const isAutoNamed = /^\d+\.md$/.test(activeNote.id) + + if (isAutoNamed && activeNote.content.trim() === '') { + const newContent = newName + '\n\n' + try { + await window.electronAPI.saveNote(activeNote.id, newContent) + setNotes((prev) => + prev.map((n) => (n.id === activeNote.id ? { ...n, content: newContent } : n)) + ) + } catch (e) { + console.error('Failed to save note', e) + } + } else { + const parts = activeNote.id.split('/') + parts.pop() + const finalName = newName.endsWith('.md') ? newName : newName + '.md' + parts.push(finalName) + const newId = parts.join('/') + + try { + const success = await window.electronAPI.renameNote(activeNote.id, newId) + if (success) { + setNotes((prev) => prev.map((n) => (n.id === activeNote.id ? { ...n, id: newId } : n))) + } + } catch (e) { + console.error('Failed to rename note', e) + } + } + } + + const handleRenameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleRenameSubmit() + if (e.key === 'Escape') setIsRenaming(false) + } + + return ( +
+ {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={handleRenameSubmit} + onKeyDown={handleRenameKeyDown} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { + e.stopPropagation() + startRename() + }} + title="Click to rename" + > + {displayTitle} + + )} +
+ ) +} diff --git a/src/hooks/useReminders.test.ts b/src/hooks/useReminders.test.ts new file mode 100644 index 0000000..8f2bfc5 --- /dev/null +++ b/src/hooks/useReminders.test.ts @@ -0,0 +1,135 @@ +import { renderHook } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { useReminders } from './useReminders' +import { SETTINGS_KEYS } from '../lib/settingsKeys' +import { useAppStore } from '../store/useAppStore' + +describe('useReminders', () => { + beforeEach(() => { + vi.useFakeTimers() + + // Mock Notification + globalThis.Notification = Object.assign(vi.fn(), { + requestPermission: vi.fn().mockResolvedValue('granted'), + permission: 'granted' as NotificationPermission, + }) + + // Mock electronAPI if not present + if (!window.electronAPI) { + window.electronAPI = { + onPowerSuspend: vi.fn().mockReturnValue(vi.fn()), + onPowerResume: vi.fn().mockReturnValue(vi.fn()), + } as Partial as any + } + + // Clear localStorage + localStorage.clear() + + // Clear mocks + vi.clearAllMocks() + + // Reset state + useAppStore.setState({ notes: [] }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should request notification permission if not granted', () => { + Object.defineProperty(Notification, 'permission', { + value: 'default', + configurable: true, + }) + + renderHook(() => useReminders()) + expect(Notification.requestPermission).toHaveBeenCalled() + }) + + it('should trigger notification for due reminders', () => { + const d = new Date(Date.now() - 60000) + const pad = (n: number) => String(n).padStart(2, '0') + const pastDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + useAppStore.setState({ + notes: [ + { + id: '1', + content: `/task Buy milk @ ${pastDate}`, + mtime: 0, + }, + ], + }) + + renderHook(() => useReminders()) + + expect(Notification).toHaveBeenCalledWith('PaperCache Reminder', { + body: 'Buy milk', + silent: false, + }) + + // Check that it's saved in localStorage + const notified = JSON.parse(localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]') + expect(notified.length).toBe(1) + }) + + it('should schedule notification for future reminders', () => { + const d2 = new Date(Date.now() + 600000) + const pad2 = (n: number) => String(n).padStart(2, '0') + const futureDate = `${d2.getFullYear()}-${pad2(d2.getMonth() + 1)}-${pad2(d2.getDate())} ${pad2(d2.getHours())}:${pad2(d2.getMinutes())}` + useAppStore.setState({ + notes: [ + { + id: '1', + content: `/task (2025-01-01 10:00) Buy bread @ ${futureDate}`, + mtime: 0, + }, + ], + }) + + renderHook(() => useReminders()) + + // Should not trigger yet + expect(Notification).not.toHaveBeenCalled() + + // Fast-forward time + vi.advanceTimersByTime(605000) + + // Now it should trigger + expect(Notification).toHaveBeenCalledWith('PaperCache Reminder', { + body: 'Buy bread', + silent: false, + }) + }) + + it('should not trigger notification for completed tasks', () => { + const d = new Date(Date.now() - 60000) + const pad = (n: number) => String(n).padStart(2, '0') + const pastDate = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` + useAppStore.setState({ + notes: [ + { + id: '1', + content: `/task-done Buy milk @ ${pastDate}`, + mtime: 0, + }, + ], + }) + + renderHook(() => useReminders()) + + expect(Notification).not.toHaveBeenCalled() + }) + + it('should handle power suspend and resume', () => { + const suspendMock = vi.fn() + const resumeMock = vi.fn() + + window.electronAPI.onPowerSuspend = suspendMock.mockReturnValue(vi.fn()) + window.electronAPI.onPowerResume = resumeMock.mockReturnValue(vi.fn()) + + renderHook(() => useReminders()) + + expect(suspendMock).toHaveBeenCalled() + expect(resumeMock).toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts index 4d39a76..3b95fe1 100644 --- a/src/hooks/useReminders.ts +++ b/src/hooks/useReminders.ts @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import { useAppStore, type Note } from '../store/useAppStore' +import { SETTINGS_KEYS } from '../lib/settingsKeys' function parseReminders(content: string, noteId: string) { const reminders: { dueAt: Date; label: string; key: string }[] = [] @@ -27,7 +28,7 @@ function parseReminders(content: string, noteId: string) { function handleDueReminders(notes: Note[]) { const now = Date.now() - const notifiedStr = localStorage.getItem('papercache_notified') || '[]' + const notifiedStr = localStorage.getItem(SETTINGS_KEYS.NOTIFIED_REMINDERS) || '[]' const notified = new Set(JSON.parse(notifiedStr)) let hasNewNotifs = false @@ -48,7 +49,7 @@ function handleDueReminders(notes: Note[]) { } if (hasNewNotifs) { - localStorage.setItem('papercache_notified', JSON.stringify(Array.from(notified))) + localStorage.setItem(SETTINGS_KEYS.NOTIFIED_REMINDERS, JSON.stringify(Array.from(notified))) } } diff --git a/src/hooks/useVariables.test.ts b/src/hooks/useVariables.test.ts new file mode 100644 index 0000000..a6a8a0d --- /dev/null +++ b/src/hooks/useVariables.test.ts @@ -0,0 +1,81 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useVariables } from './useVariables' +import { useAppStore } from '../store/useAppStore' + +declare global { + interface Window { + __globalVariables?: Record + } +} + +describe('useVariables', () => { + beforeEach(() => { + // Reset global state + window.__globalVariables = undefined + + // Reset store + useAppStore.setState({ notes: [] }) + + // Clear mocks + vi.clearAllMocks() + }) + + it('should parse simple variables', async () => { + useAppStore.setState({ + notes: [{ id: '1', content: '/globvar x = 10\n/globvar y = 20', mtime: 0 }], + }) + + renderHook(() => useVariables()) + + await waitFor(() => { + expect(window.__globalVariables).toBeDefined() + expect(window.__globalVariables?.x).toBe(10) + expect(window.__globalVariables?.y).toBe(20) + }) + }) + + it('should evaluate math expressions with previous variables', async () => { + useAppStore.setState({ + notes: [{ id: '1', content: '/globvar a = 5\n/globvar b = a * 2', mtime: 0 }], + }) + + renderHook(() => useVariables()) + + await waitFor(() => { + expect(window.__globalVariables).toBeDefined() + expect(window.__globalVariables?.a).toBe(5) + expect(window.__globalVariables?.b).toBe(10) + }) + }) + + it('should fallback to string if math evaluation fails', async () => { + useAppStore.setState({ + notes: [{ id: '1', content: '/globvar name = John Doe', mtime: 0 }], + }) + + renderHook(() => useVariables()) + + await waitFor(() => { + expect(window.__globalVariables).toBeDefined() + expect(window.__globalVariables?.name).toBe('John Doe') + }) + }) + + it('should process variables across multiple notes', async () => { + useAppStore.setState({ + notes: [ + { id: '1', content: '/globvar val1 = 100', mtime: 0 }, + { id: '2', content: '/globvar val2 = val1 + 50', mtime: 0 }, + ], + }) + + renderHook(() => useVariables()) + + await waitFor(() => { + expect(window.__globalVariables).toBeDefined() + expect(window.__globalVariables?.val1).toBe(100) + expect(window.__globalVariables?.val2).toBe(150) + }) + }) +}) diff --git a/src/lib/editor/MathEvaluator.ts b/src/lib/editor/MathEvaluator.ts new file mode 100644 index 0000000..37769b7 --- /dev/null +++ b/src/lib/editor/MathEvaluator.ts @@ -0,0 +1,76 @@ +import type { EditorView } from '@codemirror/view' +import { VariableScope } from './VariableScope' + +export class MathEvaluator { + static evalTimeout: number | null = null + + static triggerMathEvaluation(view: EditorView) { + if (this.evalTimeout) window.clearTimeout(this.evalTimeout) + this.evalTimeout = window.setTimeout(async () => { + let mathjs + try { + mathjs = await import('mathjs') + } catch { + return + } + + const docStr = view.state.doc.toString() + const scope = VariableScope.getScope() + + const changes: { from: number; to: number; insert: string }[] = [] + + // 1. Evaluate new lines that end with '=' but don't have '\u200B' yet + const lines = docStr.split('\n') + let offset = 0 + for (let i = 0; i < lines.length; i++) { + const text = lines[i] + const lineLen = text.length + + if (!text.includes('\u200B') && text.trim().endsWith('=')) { + const expr = text.substring(0, text.lastIndexOf('=')).trim() + if (expr && !expr.startsWith('/var') && !expr.startsWith('/globvar')) { + try { + const result = String(mathjs.evaluate(expr, scope)) + changes.push({ + from: offset + lineLen, + to: offset + lineLen, + insert: '\u200B' + result, + }) + } catch {} + } + } + + offset += lineLen + 1 // +1 for '\n' + } + + // 2. Re-evaluate existing calculations that already have '\u200B' + const reCalc = /^(.*?=\s*)\u200B(.*)$/gm + let calcMatch + while ((calcMatch = reCalc.exec(docStr)) !== null) { + const exprPart = calcMatch[1] + const oldResult = calcMatch[2] + const expr = exprPart.replace(/=\s*$/, '').trim() + if (expr) { + try { + const newResult = String(mathjs.evaluate(expr, scope)) + if (newResult !== oldResult) { + const startReplace = calcMatch.index + exprPart.length + 1 // +1 for \u200B + const endReplace = calcMatch.index + calcMatch[0].length + if (!changes.some((c) => c.from <= endReplace && c.to >= startReplace)) { + changes.push({ + from: startReplace, + to: endReplace, + insert: newResult, + }) + } + } + } catch {} + } + } + + if (changes.length > 0) { + view.dispatch({ changes }) + } + }, 300) + } +} diff --git a/src/lib/editor/VariableScope.ts b/src/lib/editor/VariableScope.ts new file mode 100644 index 0000000..7b62a98 --- /dev/null +++ b/src/lib/editor/VariableScope.ts @@ -0,0 +1,64 @@ +import type { EditorView } from '@codemirror/view' +import { StateEffect } from '@codemirror/state' + +export const scopeChangedEffect = StateEffect.define() + +export class VariableScope { + static globalScopeCache: Record = {} + static lastDocString = '' + static scopeEvalTimeout: number | null = null + static scopeVersion = 0 + + static triggerScopeUpdate(docStr: string, view: EditorView | null) { + if (docStr === this.lastDocString) return + this.lastDocString = docStr + if (this.scopeEvalTimeout) window.clearTimeout(this.scopeEvalTimeout) + this.scopeVersion++ + const currentVersion = this.scopeVersion + this.scopeEvalTimeout = window.setTimeout(async () => { + let mathjs + try { + mathjs = await import('mathjs') + } catch { + return + } + + const newScope: Record = {} + const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm + let varMatch + let changed = false + + const globalVars = + (window as unknown as { __globalVariables: Record }).__globalVariables || + {} + + while ((varMatch = reVar.exec(docStr)) !== null) { + const name = varMatch[1] + try { + const val = mathjs.evaluate(varMatch[2], Object.assign({}, globalVars, newScope)) + newScope[name] = val + } catch { + newScope[name] = varMatch[2].trim() + } + if (this.globalScopeCache[name] !== newScope[name]) { + changed = true + } + } + + if (currentVersion !== this.scopeVersion) return + if (changed || Object.keys(this.globalScopeCache).length !== Object.keys(newScope).length) { + this.globalScopeCache = newScope + if (view) { + view.dispatch({ effects: [scopeChangedEffect.of()] }) + import('./MathEvaluator').then((m) => m.MathEvaluator.triggerMathEvaluation(view)) + } + } + }, 300) + } + + static getScope(): Record { + const globalVars = + (window as unknown as { __globalVariables: Record }).__globalVariables || {} + return Object.assign({}, globalVars, this.globalScopeCache) + } +} diff --git a/src/lib/editor/checkboxPlugin.ts b/src/lib/editor/checkboxPlugin.ts new file mode 100644 index 0000000..03cb3c4 --- /dev/null +++ b/src/lib/editor/checkboxPlugin.ts @@ -0,0 +1,74 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate } from '@codemirror/view' +import { CheckboxWidget } from './widgets' + +export const checkboxPlugin = ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + let match + + // Checkboxes (/check, /checked) + const reCheck = /\/(check(?:ed)?)\b/g + while ((match = reCheck.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const isChecked = match[1] === 'checked' + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new CheckboxWidget(isChecked, start, view) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-check-highlight' }), + }) + } + + if (isChecked) { + const line = view.state.doc.lineAt(start) + if (line.to > end) { + decos.push({ + from: end, + to: line.to, + deco: Decoration.mark({ class: 'cm-checked-line-text' }), + }) + } + } + } + } + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) diff --git a/src/lib/editor/codeBlockPlugin.ts b/src/lib/editor/codeBlockPlugin.ts new file mode 100644 index 0000000..ed3d325 --- /dev/null +++ b/src/lib/editor/codeBlockPlugin.ts @@ -0,0 +1,113 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' +import { syntaxTree } from '@codemirror/language' +import { CopyWidget } from './widgets' + +export const codeBlockPlugin = ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + syntaxTree(view.state).iterate({ + enter: (node: SyntaxNode) => { + if (node.type.name === 'FencedCode') { + let lang = '' + let code = '' + let startCodeMark: SyntaxNode | null = null + let endCodeMark: SyntaxNode | null = null + let codeInfo: SyntaxNode | null = null + + let child = node.node.firstChild + while (child) { + if (child.type.name === 'CodeInfo') { + lang = view.state.doc.sliceString(child.from, child.to) + codeInfo = child + } + if (child.type.name === 'CodeText') { + code = view.state.doc.sliceString(child.from, child.to) + } + if (child.type.name === 'CodeMark') { + if (!startCodeMark) startCodeMark = child + else endCodeMark = child + } + child = child.nextSibling + } + + const start = node.from + const end = node.to + + if (!isCursorInMatch(start, end)) { + if (startCodeMark) { + const replaceTo = codeInfo ? codeInfo.to : startCodeMark.to + decos.push({ + from: startCodeMark.from, + to: replaceTo, + deco: Decoration.replace({}), + }) + } + if (endCodeMark) { + decos.push({ + from: endCodeMark.from, + to: endCodeMark.to, + deco: Decoration.replace({}), + }) + } + } else { + if (codeInfo && !isCursorInMatch(codeInfo.from, codeInfo.to)) { + decos.push({ from: codeInfo.from, to: codeInfo.to, deco: Decoration.replace({}) }) + } + } + + if (startCodeMark) { + decos.push({ + from: startCodeMark.from, + to: startCodeMark.from, + deco: Decoration.widget({ widget: new CopyWidget(code, lang), side: 1 }), + }) + } + + const startLine = view.state.doc.lineAt(start).number + const endLine = view.state.doc.lineAt(end).number + for (let i = startLine; i <= endLine; i++) { + const line = view.state.doc.line(i) + let className = 'cm-code-block-line' + if (i === startLine) className += ' cm-code-block-first' + if (i === endLine) className += ' cm-code-block-last' + decos.push({ + from: line.from, + to: line.from, + deco: Decoration.line({ class: className }), + }) + } + } + }, + }) + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) diff --git a/src/lib/editor/formatPlugin.ts b/src/lib/editor/formatPlugin.ts new file mode 100644 index 0000000..77e45d7 --- /dev/null +++ b/src/lib/editor/formatPlugin.ts @@ -0,0 +1,144 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate } from '@codemirror/view' +import { ColorWidget } from './widgets' + +export const formatPlugin = ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + let match + + // Color Formats + const reColor = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g + while ((match = reColor.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new ColorWidget(match[0]) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ + class: 'cm-color-highlight', + attributes: { style: `--pill-color: ${match[0]}` }, + }), + }) + } + } + + // Date Formats (YYYY-MM-DD or DD-MM-YYYY) + const reDate = /\b(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})\b/g + while ((match = reDate.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-date-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-date-highlight' }), + }) + } + } + + // Time Formats (HH:MM or HH:MM:SS) + const reTime = /\b\d{2}:\d{2}(?::\d{2})?\b/g + while ((match = reTime.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-time-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-time-highlight' }), + }) + } + } + + // Currency Formats + const reCurrency = /[$€£¥₹]\s*\d+(?:,\d{3})*(?:\.\d{1,2})?/g + while ((match = reCurrency.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-currency-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-currency-highlight' }), + }) + } + } + + // Tags (!tag) + const reTag = /![a-zA-Z0-9_-]+/g + while ((match = reTag.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-tag-pill' }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-tag-highlight' }), + }) + } + } + } + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) diff --git a/src/lib/editor/markdownPlugin.ts b/src/lib/editor/markdownPlugin.ts new file mode 100644 index 0000000..3d7867b --- /dev/null +++ b/src/lib/editor/markdownPlugin.ts @@ -0,0 +1,190 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' +import { syntaxTree } from '@codemirror/language' +import { ContextWidget } from './widgets' + +export const markdownPlugin = ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + const linkRanges: { from: number; to: number }[] = [] + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + let match + + // ==Highlight== + const reHighlight = /==(.*?)==/g + while ((match = reHighlight.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (start + 2 <= end - 2) { + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: start + 2, deco: Decoration.replace({}) }) + decos.push({ from: end - 2, to: end, deco: Decoration.replace({}) }) + } + decos.push({ + from: start + 2, + to: end - 2, + deco: Decoration.mark({ class: 'cm-custom-highlight' }), + }) + } + } + + // Lists + const reList = /^(\s*)\*\s+/gm + while ((match = reList.exec(text)) !== null) { + const start = from + match.index + match[1].length + const end = start + 1 + if (!isCursorInMatch(start, end + 1)) { + decos.push({ from: start, to: end, deco: Decoration.replace({}) }) + } + } + + // Headings + const reHeading = /^#{1,6}\s+/gm + while ((match = reHeading.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: end, deco: Decoration.replace({}) }) + } + } + + // Links + const reLink = /\[(.*?)\]\((.*?)\)/g + while ((match = reLink.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + linkRanges.push({ from: start, to: end }) + + const textStart = start + 1 + const textEnd = start + 1 + match[1].length + const urlStart = textEnd + const urlEnd = end + + let isFile = false + let linkPath = match[2].trim() + + if (linkPath.startsWith('/file')) { + isFile = true + linkPath = linkPath.substring(5).trim() + } else if (linkPath.startsWith('/url')) { + linkPath = linkPath.substring(4).trim() + } + + if (!isCursorInMatch(start, end)) { + decos.push({ from: start, to: textStart, deco: Decoration.replace({}) }) + decos.push({ from: urlStart, to: urlEnd, deco: Decoration.replace({}) }) + } + + if (isFile) { + decos.push({ + from: textStart, + to: textEnd, + deco: Decoration.mark({ + class: 'cm-custom-file-link', + attributes: { 'data-path': linkPath, title: 'Open file: ' + linkPath }, + }), + }) + } else { + decos.push({ + from: textStart, + to: textEnd, + deco: Decoration.mark({ + class: 'cm-custom-clickable-link', + attributes: { 'data-url': linkPath, title: linkPath }, + }), + }) + } + } + + // Context Command (/ctx, /context) + const reCtx = /^\/(ctx|context)\b/gm + while ((match = reCtx.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new ContextWidget() }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-ctx-highlight' }), + }) + } + } + } + + // Traverse AST for Markdown syntax + syntaxTree(view.state).iterate({ + enter: (node: SyntaxNode) => { + if (node.type.name === 'EmphasisMark' || node.type.name === 'StrongMark') { + const parent = node.node.parent + if (parent) { + const start = parent.from + const end = parent.to + if (!isCursorInMatch(start, end)) { + decos.push({ from: node.from, to: node.to, deco: Decoration.replace({}) }) + } + } + } + + if (node.type.name === 'HorizontalRule') { + const start = node.from + const end = node.to + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ + widget: new (class extends WidgetType { + eq() { + return true + } + toDOM() { + const hr = document.createElement('hr') + hr.className = 'cm-hr' + return hr + } + })(), + }), + }) + } + } + }, + }) + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) diff --git a/src/lib/editor/plugins.ts b/src/lib/editor/plugins.ts index d2730bd..55d0a9b 100644 --- a/src/lib/editor/plugins.ts +++ b/src/lib/editor/plugins.ts @@ -1,19 +1,12 @@ -import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@codemirror/view' -import type { SyntaxNode } from '@lezer/common' -import { syntaxTree } from '@codemirror/language' +import { ViewPlugin, ViewUpdate, EditorView } from '@codemirror/view' import { numberMatcher, symbolMatcher, aiMatcher, mathMatcher } from './matchers' -import { - CopyWidget, - CheckboxWidget, - VariableWidget, - ReminderWidget, - ContextWidget, - ColorWidget, -} from './widgets' - -let globalScopeCache: Record = {} -let scopeEvalTimeout: number | null = null -let lastDocString = '' +import { variablePlugin } from './variablePlugin' +import { formatPlugin } from './formatPlugin' +import { checkboxPlugin } from './checkboxPlugin' +import { taskPlugin, remConverterPlugin } from './taskPlugin' +import { markdownPlugin } from './markdownPlugin' +import { codeBlockPlugin } from './codeBlockPlugin' +import { VariableScope } from './VariableScope' export const numberPlugin = ViewPlugin.fromClass( class { @@ -67,614 +60,26 @@ export const mathPlugin = ViewPlugin.fromClass( { decorations: (v) => v.decorations } ) -export const hideMarkdownPlugin = ViewPlugin.fromClass( +export const scopeUpdaterPlugin = ViewPlugin.fromClass( class { - decorations constructor(view: EditorView) { - this.decorations = this.buildDeco(view) + VariableScope.triggerScopeUpdate(view.state.doc.toString(), view) } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = this.buildDeco(update.view) - } - } - - buildDeco(view: EditorView) { - const decos: { from: number; to: number; deco: Decoration }[] = [] - - const selectionRanges = view.state.selection.ranges - const isCursorInMatch = (start: number, end: number) => { - return selectionRanges.some( - (r: { from: number; to: number }) => r.from <= end && r.to >= start - ) - } - - const linkRanges: { from: number; to: number }[] = [] - const fullDoc = view.state.doc.toString() - - // Build variable scope (incorporate global variables) synchronously from cache - const scope: Record = Object.assign( - {}, - (window as unknown as { __globalVariables: Record }).__globalVariables || - {}, - globalScopeCache - ) - const scopeKeys = Object.keys(scope).sort((a, b) => b.length - a.length) - - // Trigger debounced evaluation if document changed - if (fullDoc !== lastDocString) { - lastDocString = fullDoc - if (scopeEvalTimeout) clearTimeout(scopeEvalTimeout) - scopeEvalTimeout = window.setTimeout(async () => { - let mathjs: any - try { - mathjs = await import('mathjs') - } catch { - return - } - - const newScope: Record = {} - const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm - let varMatch - let changed = false - while ((varMatch = reVar.exec(fullDoc)) !== null) { - const name = varMatch[1] - try { - const val = mathjs.evaluate( - varMatch[2], - Object.assign( - {}, - (window as unknown as { __globalVariables: Record }) - .__globalVariables || {}, - newScope - ) - ) - newScope[name] = val - } catch { - newScope[name] = varMatch[2].trim() - } - if (globalScopeCache[name] !== newScope[name]) { - changed = true - } - } - if (changed || Object.keys(globalScopeCache).length !== Object.keys(newScope).length) { - globalScopeCache = newScope - // dispatch an empty transaction to force re-render with new cache - view.dispatch({ effects: [] }) - } - }, 300) - } - - for (const { from, to } of view.visibleRanges) { - const text = view.state.doc.sliceString(from, to) - - const reHighlight = /==(.*?)==/g - let match - while ((match = reHighlight.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (start + 2 <= end - 2) { - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: start + 2, deco: Decoration.replace({}) }) - decos.push({ from: end - 2, to: end, deco: Decoration.replace({}) }) - } - decos.push({ - from: start + 2, - to: end - 2, - deco: Decoration.mark({ class: 'cm-custom-highlight' }), - }) - } - } - - const reList = /^(\s*)\*\s+/gm - while ((match = reList.exec(text)) !== null) { - const start = from + match.index + match[1].length - const end = start + 1 // only the asterisk - if (!isCursorInMatch(start, end + 1)) { - decos.push({ from: start, to: end, deco: Decoration.replace({}) }) - } - } - - // Handled by syntaxTree below - - const reHeading = /^#{1,6}\s+/gm - while ((match = reHeading.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: end, deco: Decoration.replace({}) }) - } - } - - const reLink = /\[(.*?)\]\((.*?)\)/g - while ((match = reLink.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - linkRanges.push({ from: start, to: end }) - - const textStart = start + 1 - const textEnd = start + 1 + match[1].length - const urlStart = textEnd - const urlEnd = end - - let isFile = false - let linkPath = match[2].trim() - - if (linkPath.startsWith('/file')) { - isFile = true - linkPath = linkPath.substring(5).trim() - } else if (linkPath.startsWith('/url')) { - linkPath = linkPath.substring(4).trim() - } - - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: textStart, deco: Decoration.replace({}) }) - decos.push({ from: urlStart, to: urlEnd, deco: Decoration.replace({}) }) - } - - if (isFile) { - decos.push({ - from: textStart, - to: textEnd, - deco: Decoration.mark({ - class: 'cm-custom-file-link', - attributes: { 'data-path': linkPath, title: 'Open file: ' + linkPath }, - }), - }) - } else { - decos.push({ - from: textStart, - to: textEnd, - deco: Decoration.mark({ - class: 'cm-custom-clickable-link', - attributes: { 'data-url': linkPath, title: linkPath }, - }), - }) - } - } - - const reFile = /\/file\s+([^\s)\]]+)/g - while ((match = reFile.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - - if (linkRanges.some((r) => r.from <= start && r.to >= end)) continue - - const pathStart = start + match[0].indexOf(match[1]) - - if (!isCursorInMatch(start, end)) { - decos.push({ from: start, to: pathStart, deco: Decoration.replace({}) }) - } - - decos.push({ - from: pathStart, - to: end, - deco: Decoration.mark({ - class: 'cm-custom-file-link', - attributes: { 'data-path': match[1], title: 'Open file: ' + match[1] }, - }), - }) - } - - // Variable rendering - if (scopeKeys.length > 0) { - const reKeys = new RegExp(`\\b(${scopeKeys.join('|')})\\b`, 'g') - while ((match = reKeys.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const line = view.state.doc.lineAt(start) - if (line.text.trim().startsWith('/var')) continue // don't replace inside variable definitions! - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new VariableWidget(String(scope[match[1]])) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-variable-highlight' }), - }) - } - } - } - - // Color Formats - const reColor = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b/g - while ((match = reColor.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new ColorWidget(match[0]) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ - class: 'cm-color-highlight', - attributes: { style: `--pill-color: ${match[0]}` }, - }), - }) - } - } - - // Date Formats (YYYY-MM-DD or DD-MM-YYYY) - const reDate = /\b(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})\b/g - while ((match = reDate.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-date-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-date-highlight' }), - }) - } - } - - // Time Formats (HH:MM or HH:MM:SS) - const reTime = /\b\d{2}:\d{2}(?::\d{2})?\b/g - while ((match = reTime.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-time-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-time-highlight' }), - }) - } - } - - // Currency Formats - const reCurrency = /[$€£¥₹]\s*\d+(?:,\d{3})*(?:\.\d{1,2})?/g - while ((match = reCurrency.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-currency-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-currency-highlight' }), - }) - } - } - - // Tags (!tag) - const reTag = /![a-zA-Z0-9_-]+/g - while ((match = reTag.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-tag-pill' }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-tag-highlight' }), - }) - } - } - - // Checkboxes (/check, /checked) - const reCheck = /\/(check(?:ed)?)\b/g - while ((match = reCheck.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const isChecked = match[1] === 'checked' - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new CheckboxWidget(isChecked, start, view) }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-check-highlight' }), - }) - } - - if (isChecked) { - const line = view.state.doc.lineAt(start) - if (line.to > end) { - decos.push({ - from: end, - to: line.to, - deco: Decoration.mark({ class: 'cm-checked-line-text' }), - }) - } - } - } - - // Context Command (/ctx, /context) - const reCtx = /^\/(ctx|context)\b/gm - while ((match = reCtx.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ widget: new ContextWidget() }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-ctx-highlight' }), - }) - } - } - - // Tasks (/task, /task-done) - const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g - while ((match = reRem.exec(text)) !== null) { - const start = from + match.index - const end = start + match[0].length - const isChecked = match[1] === 'task-done' - - const line = view.state.doc.lineAt(start) - let isOverdue = false - const fullRe = - /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/ - const fullMatch = fullRe.exec(line.text) - if (fullMatch && fullMatch[4]) { - if (new Date(fullMatch[4]).getTime() < Date.now()) isOverdue = true - } - - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ - widget: new ReminderWidget(isChecked, isOverdue, start, view), - }), - }) - } else { - decos.push({ - from: start, - to: end, - deco: Decoration.mark({ class: 'cm-rem-highlight' }), - }) - } - - if (line.to > end) { - let classStr = 'cm-rem-line-text' - if (isChecked) classStr += ' cm-checked-line-text' - else if (isOverdue) classStr += ' cm-overdue-line-text' - - decos.push({ - from: end, - to: line.to, - deco: Decoration.mark({ class: classStr }), - }) - } - } - } // end of visibleRanges iteration - - // Traverse AST for Code Blocks - syntaxTree(view.state).iterate({ - enter: (node) => { - if (node.type.name === 'FencedCode') { - let lang = '' - let code = '' - let startCodeMark: SyntaxNode | null = null - let endCodeMark: SyntaxNode | null = null - let codeInfo: SyntaxNode | null = null - - let child = node.node.firstChild - while (child) { - if (child.type.name === 'CodeInfo') { - lang = view.state.doc.sliceString(child.from, child.to) - codeInfo = child - } - if (child.type.name === 'CodeText') - code = view.state.doc.sliceString(child.from, child.to) - if (child.type.name === 'CodeMark') { - if (!startCodeMark) startCodeMark = child - else endCodeMark = child - } - child = child.nextSibling - } - - const start = node.from - const end = node.to - - if (!isCursorInMatch(start, end)) { - if (startCodeMark) { - const replaceTo = codeInfo ? codeInfo.to : startCodeMark.to - decos.push({ - from: startCodeMark.from, - to: replaceTo, - deco: Decoration.replace({}), - }) - } - if (endCodeMark) { - decos.push({ - from: endCodeMark.from, - to: endCodeMark.to, - deco: Decoration.replace({}), - }) - } - } else { - if (codeInfo && !isCursorInMatch(codeInfo.from, codeInfo.to)) { - decos.push({ from: codeInfo.from, to: codeInfo.to, deco: Decoration.replace({}) }) - } - } - - if (startCodeMark) { - decos.push({ - from: startCodeMark.from, - to: startCodeMark.from, - deco: Decoration.widget({ widget: new CopyWidget(code, lang), side: 1 }), - }) - } - - const startLine = view.state.doc.lineAt(start).number - const endLine = view.state.doc.lineAt(end).number - for (let i = startLine; i <= endLine; i++) { - const line = view.state.doc.line(i) - let className = 'cm-code-block-line' - if (i === startLine) className += ' cm-code-block-first' - if (i === endLine) className += ' cm-code-block-last' - decos.push({ - from: line.from, - to: line.from, - deco: Decoration.line({ class: className }), - }) - } - } - - if (node.type.name === 'EmphasisMark' || node.type.name === 'StrongMark') { - const parent = node.node.parent - if (parent) { - const start = parent.from - const end = parent.to - if (!isCursorInMatch(start, end)) { - decos.push({ from: node.from, to: node.to, deco: Decoration.replace({}) }) - } - } - } - - if (node.type.name === 'HorizontalRule') { - const start = node.from - const end = node.to - if (!isCursorInMatch(start, end)) { - decos.push({ - from: start, - to: end, - deco: Decoration.replace({ - widget: new (class extends WidgetType { - eq() { - return true - } - toDOM() { - const hr = document.createElement('hr') - hr.className = 'cm-hr' - return hr - } - })(), - }), - }) - } - } - }, - }) - - try { - const ranges = decos.map((d) => d.deco.range(d.from, d.to)) - return Decoration.set(ranges, true) - } catch (e) { - console.error('Decoration builder error:', e) - return Decoration.none - } - } - }, - { decorations: (v) => v.decorations } -) - -export const remConverterPlugin = ViewPlugin.fromClass( - class { - update(update: ViewUpdate) { - if (!update.docChanged) return - - const docStr = update.state.doc.toString() - const changes: { from: number; to: number; insert: string }[] = [] - - // Match /task or /task-done followed by a space, but ONLY if not already followed by a date bracket ( - const re = /^\/(task|task-done) (?!\()/gm - let match - while ((match = re.exec(docStr)) !== null) { - const now = new Date() - const yyyy = now.getFullYear() - const mm = String(now.getMonth() + 1).padStart(2, '0') - const dd = String(now.getDate()).padStart(2, '0') - const hh = String(now.getHours()).padStart(2, '0') - const mins = String(now.getMinutes()).padStart(2, '0') - - const timestamp = `(${yyyy}-${mm}-${dd} ${hh}:${mins})` - - changes.push({ - from: match.index, - to: match.index + match[0].length, - insert: `/${match[1]} ${timestamp} `, - }) - } - - // Match shorthand timers at the end of a task, ONLY after a space or Enter is typed - const reShort = - /^(\/(?:task|task-done)[^\n]*?@\s*)((?:[0-9]+[smhd])+|tmrw)([ \t]+|\n|(?:\r\n))/gm - while ((match = reShort.exec(docStr)) !== null) { - const now = new Date() - const short = match[2] - if (short === 'tmrw') { - now.setDate(now.getDate() + 1) - now.setHours(9, 0, 0, 0) - } else { - const partRe = /([0-9]+)([smhd])/g - let partMatch - while ((partMatch = partRe.exec(short)) !== null) { - const val = parseInt(partMatch[1]) - const unit = partMatch[2] - if (unit === 's') now.setSeconds(now.getSeconds() + val) - else if (unit === 'm') now.setMinutes(now.getMinutes() + val) - else if (unit === 'h') now.setHours(now.getHours() + val) - else if (unit === 'd') now.setDate(now.getDate() + val) - } - } - - const yyyy = now.getFullYear() - const mm = String(now.getMonth() + 1).padStart(2, '0') - const dd = String(now.getDate()).padStart(2, '0') - const hh = String(now.getHours()).padStart(2, '0') - const mins = String(now.getMinutes()).padStart(2, '0') - - const absoluteDate = `${yyyy}-${mm}-${dd} ${hh}:${mins}` - - // Push the change to replace ONLY the shorthand part - changes.push({ - from: match.index + match[1].length, - to: match.index + match[1].length + match[2].length, - insert: absoluteDate, - }) - } - - if (changes.length > 0) { - setTimeout(() => { - update.view.dispatch({ changes }) - }, 10) + if (update.docChanged) { + VariableScope.triggerScopeUpdate(update.state.doc.toString(), update.view) } } } ) + +export const decomposedPlugins = [ + variablePlugin, + formatPlugin, + checkboxPlugin, + taskPlugin, + markdownPlugin, + codeBlockPlugin, + remConverterPlugin, + scopeUpdaterPlugin, +] diff --git a/src/lib/editor/taskPlugin.ts b/src/lib/editor/taskPlugin.ts new file mode 100644 index 0000000..56f6ed1 --- /dev/null +++ b/src/lib/editor/taskPlugin.ts @@ -0,0 +1,164 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate } from '@codemirror/view' +import { ReminderWidget } from './widgets' + +export const taskPlugin = ViewPlugin.fromClass( + class { + timeout: number | undefined + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + let match + + // Tasks (/task, /task-done) + const reRem = /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+/g + while ((match = reRem.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const isChecked = match[1] === 'task-done' + + const line = view.state.doc.lineAt(start) + let isOverdue = false + const fullRe = + /\/(task(?:-done)?)(?:\s+\((\d{4}-\d{2}-\d{2} \d{2}:\d{2})\))?\s+(.*?)(?:\s+@\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?))?[ \t]*$/ + const fullMatch = fullRe.exec(line.text) + if (fullMatch && fullMatch[4]) { + if (new Date(fullMatch[4]).getTime() < Date.now()) isOverdue = true + } + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ + widget: new ReminderWidget(isChecked, isOverdue, start, view), + }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-rem-highlight' }), + }) + } + + if (line.to > end) { + let classStr = 'cm-rem-line-text' + if (isChecked) classStr += ' cm-checked-line-text' + else if (isOverdue) classStr += ' cm-overdue-line-text' + + decos.push({ + from: end, + to: line.to, + deco: Decoration.mark({ class: classStr }), + }) + } + } + } + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) + +export const remConverterPlugin = ViewPlugin.fromClass( + class { + update(update: ViewUpdate) { + if (!update.docChanged) return + + const docStr = update.state.doc.toString() + const changes: { from: number; to: number; insert: string }[] = [] + + // Match /task or /task-done followed by a space, but ONLY if not already followed by a date bracket ( + const re = /^\/(task|task-done) (?!\()/gm + let match + while ((match = re.exec(docStr)) !== null) { + const now = new Date() + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const timestamp = `(${yyyy}-${mm}-${dd} ${hh}:${mins})` + + changes.push({ + from: match.index, + to: match.index + match[0].length, + insert: `/${match[1]} ${timestamp} `, + }) + } + + // Match shorthand timers at the end of a task, ONLY after a space or Enter is typed + const reShort = + /^(\/(?:task|task-done)[^\n]*?@\s*)((?:[0-9]+[smhd])+|tmrw)([ \t]+|\n|(?:\r\n))/gm + while ((match = reShort.exec(docStr)) !== null) { + const now = new Date() + const short = match[2] + if (short === 'tmrw') { + now.setDate(now.getDate() + 1) + now.setHours(9, 0, 0, 0) + } else { + const partRe = /([0-9]+)([smhd])/g + let partMatch + while ((partMatch = partRe.exec(short)) !== null) { + const val = parseInt(partMatch[1]) + const unit = partMatch[2] + if (unit === 's') now.setSeconds(now.getSeconds() + val) + else if (unit === 'm') now.setMinutes(now.getMinutes() + val) + else if (unit === 'h') now.setHours(now.getHours() + val) + else if (unit === 'd') now.setDate(now.getDate() + val) + } + } + + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const mins = String(now.getMinutes()).padStart(2, '0') + + const absoluteDate = `${yyyy}-${mm}-${dd} ${hh}:${mins}` + + changes.push({ + from: match.index + match[1].length, + to: match.index + match[1].length + match[2].length, + insert: absoluteDate, + }) + } + + if (changes.length > 0) { + // @ts-expect-error adding property to anonymous class + if (this.timeout) clearTimeout(this.timeout) + // @ts-expect-error adding property to anonymous class + this.timeout = setTimeout(() => { + update.view.dispatch({ changes }) + }, 10) + } + } + } +) diff --git a/src/lib/editor/variablePlugin.ts b/src/lib/editor/variablePlugin.ts new file mode 100644 index 0000000..6ddbc1d --- /dev/null +++ b/src/lib/editor/variablePlugin.ts @@ -0,0 +1,77 @@ +import { ViewPlugin, Decoration, EditorView, ViewUpdate } from '@codemirror/view' +import { VariableWidget } from './widgets' +import { VariableScope, scopeChangedEffect } from './VariableScope' + +export const variablePlugin = ViewPlugin.fromClass( + class { + decorations + + constructor(view: EditorView) { + this.decorations = this.buildDeco(view) + } + + update(update: ViewUpdate) { + const scopeChanged = update.transactions.some((tr) => + tr.effects.some((e) => e.is(scopeChangedEffect)) + ) + if (update.docChanged || update.viewportChanged || update.selectionSet || scopeChanged) { + this.decorations = this.buildDeco(update.view) + } + } + + buildDeco(view: EditorView) { + const decos: { from: number; to: number; deco: Decoration }[] = [] + const selectionRanges = view.state.selection.ranges + const isCursorInMatch = (start: number, end: number) => { + return selectionRanges.some( + (r: { from: number; to: number }) => r.from <= end && r.to >= start + ) + } + + const scope = VariableScope.getScope() + const scopeKeys = Object.keys(scope).sort((a, b) => b.length - a.length) + + if (scopeKeys.length === 0) return Decoration.none + + const escapeRegex = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const escapedKeys = scopeKeys.map(escapeRegex) + const reKeys = new RegExp(`\\b(${escapedKeys.join('|')})\\b`, 'g') + + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + let match + + while ((match = reKeys.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + const line = view.state.doc.lineAt(start) + + if (line.text.trim().startsWith('/var') || line.text.trim().startsWith('/globvar')) + continue + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ widget: new VariableWidget(String(scope[match[1]])) }), + }) + } else { + decos.push({ + from: start, + to: end, + deco: Decoration.mark({ class: 'cm-variable-highlight' }), + }) + } + } + } + + try { + const ranges = decos.map((d) => d.deco.range(d.from, d.to)) + return Decoration.set(ranges, true) + } catch { + return Decoration.none + } + } + }, + { decorations: (v) => v.decorations } +) diff --git a/src/lib/editor/widgets.test.ts b/src/lib/editor/widgets.test.ts new file mode 100644 index 0000000..6f38eea --- /dev/null +++ b/src/lib/editor/widgets.test.ts @@ -0,0 +1,96 @@ +import type { EditorView } from '@codemirror/view' +import { describe, it, expect, vi } from 'vitest' +import { CopyWidget, CheckboxWidget, VariableWidget, ColorWidget } from './widgets' + +describe('Editor Widgets', () => { + describe('CopyWidget', () => { + it('should create DOM with correct classes', () => { + const widget = new CopyWidget('const x = 1', 'typescript') + const dom = widget.toDOM() + + expect(dom.tagName).toBe('SPAN') + expect(dom.className).toBe('cm-copy-button') + expect(dom.querySelector('.cm-code-lang')?.textContent).toBe('typescript') + expect(dom.querySelector('svg')).not.toBeNull() + }) + + it('should check equality correctly', () => { + const w1 = new CopyWidget('code', 'js') + const w2 = new CopyWidget('code', 'js') + const w3 = new CopyWidget('code2', 'js') + + expect(w1.eq(w2)).toBe(true) + expect(w1.eq(w3)).toBe(false) + }) + }) + + describe('CheckboxWidget', () => { + it('should create unchecked DOM correctly', () => { + const mockView: Partial = { dispatch: vi.fn() } + const widget = new CheckboxWidget(false, 10, mockView as EditorView) + const dom = widget.toDOM() + + expect(dom.className).toBe('cm-checkbox-widget') + expect(dom.innerHTML).toBe('') + }) + + it('should create checked DOM correctly', () => { + const mockView: Partial = { dispatch: vi.fn() } + const widget = new CheckboxWidget(true, 10, mockView as EditorView) + const dom = widget.toDOM() + + expect(dom.className).toBe('cm-checkbox-widget cm-checkbox-checked') + expect(dom.querySelector('svg')).not.toBeNull() + }) + + it('should dispatch correct transaction on click', () => { + const mockView: Partial = { dispatch: vi.fn() } + + // Unchecked -> Checked + const widgetUnchecked = new CheckboxWidget(false, 10, mockView as EditorView) + const domUnchecked = widgetUnchecked.toDOM() + domUnchecked.onclick?.({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as PointerEvent) + + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { from: 10, to: 16, insert: '/checked' }, + }) + + vi.clearAllMocks() + + // Checked -> Unchecked + const widgetChecked = new CheckboxWidget(true, 10, mockView as EditorView) + const domChecked = widgetChecked.toDOM() + domChecked.onclick?.({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as PointerEvent) + + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { from: 10, to: 18, insert: '/check' }, + }) + }) + }) + + describe('VariableWidget', () => { + it('should create DOM with correct value', () => { + const widget = new VariableWidget('42') + const dom = widget.toDOM() + + expect(dom.className).toBe('cm-variable-pill') + expect(dom.textContent).toBe('42') + }) + }) + + describe('ColorWidget', () => { + it('should create DOM with correct color style', () => { + const widget = new ColorWidget('#ff0000') + const dom = widget.toDOM() + + expect(dom.className).toBe('cm-color-pill') + expect(dom.style.getPropertyValue('--pill-color')).toBe('#ff0000') + }) + }) +}) diff --git a/src/lib/settingsKeys.ts b/src/lib/settingsKeys.ts new file mode 100644 index 0000000..609a614 --- /dev/null +++ b/src/lib/settingsKeys.ts @@ -0,0 +1,20 @@ +export const SETTINGS_KEYS = { + THEME_PRESET: 'papercache-theme-preset', + FONT_FAMILY: 'papercache-font', + SHOW_RULINGS: 'papercache-rulings', + BG_TYPE: 'papercache-bg-type', + BG_COLOR: 'papercache-bg-color', + BG_IMAGE: 'papercache-bg-image', + TEXT_COLOR: 'papercache-color-text', + NUM_COLOR: 'papercache-color-num', + SYM_COLOR: 'papercache-color-sym', + AI_COLOR: 'papercache-ai-color', + MATH_COLOR: 'papercache-math-color', + API_BASE_URL: 'papercache-api-base-url', + API_MODEL: 'papercache-api-model', + AI_SYSTEM_PROMPT: 'papercache-ai-system-prompt', + SHORTCUT_NEWNOTE: 'papercache-shortcut-newnote', + SHORTCUT_TOGGLE: 'papercache-shortcut-toggle', + LAUNCH_STARTUP: 'papercache-launch-startup', + NOTIFIED_REMINDERS: 'papercache_notified', +} as const diff --git a/src/setupTests.ts b/src/setupTests.ts index db112e3..aff2f7a 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -25,6 +25,8 @@ if (!window.electronAPI) { openSettings: () => {}, closeWindow: () => {}, quitApp: () => {}, + onPowerSuspend: () => () => {}, + onPowerResume: () => () => {}, setLaunchAtStartup: () => {}, updateGlobalShortcut: () => {}, readNote: async () => '', @@ -33,5 +35,5 @@ if (!window.electronAPI) { openFile: () => {}, safeStorageEncrypt: async (val: string) => val, safeStorageDecrypt: async (val: string) => val, - } as any + } as unknown as typeof window.electronAPI } diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts new file mode 100644 index 0000000..a7b9864 --- /dev/null +++ b/src/store/useAppStore.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useAppStore } from './useAppStore' + +describe('useAppStore', () => { + beforeEach(() => { + // Reset state before each test + useAppStore.setState({ + notes: [], + currentNoteIndex: 0, + zoomLevel: 1, + showGraphView: false, + showRemindersView: false, + isRenaming: false, + renameValue: '', + showNoteSearch: false, + noteSearchQuery: '', + searchSelectedIndex: 0, + showNoteActionMenu: false, + showMainActionMenu: false, + actionMenuIndex: 0, + }) + }) + + it('should initialize with default state', () => { + const state = useAppStore.getState() + expect(state.notes).toEqual([]) + expect(state.currentNoteIndex).toBe(0) + expect(state.zoomLevel).toBe(1) + expect(state.showNoteSearch).toBe(false) + }) + + it('should set notes and support functional updates', () => { + const { setNotes } = useAppStore.getState() + + // Set notes directly + setNotes([{ id: '1', content: 'test', mtime: 123 }]) + expect(useAppStore.getState().notes).toHaveLength(1) + expect(useAppStore.getState().notes[0].content).toBe('test') + + // Functional update + setNotes((prev) => [...prev, { id: '2', content: 'test2', mtime: 456 }]) + expect(useAppStore.getState().notes).toHaveLength(2) + }) + + it('should set current note index', () => { + useAppStore.getState().setCurrentNoteIndex(2) + expect(useAppStore.getState().currentNoteIndex).toBe(2) + }) + + it('should handle functional updates for boolean toggles', () => { + const { setShowGraphView, setShowNoteSearch } = useAppStore.getState() + + setShowGraphView(true) + expect(useAppStore.getState().showGraphView).toBe(true) + + setShowGraphView((prev) => !prev) + expect(useAppStore.getState().showGraphView).toBe(false) + + setShowNoteSearch(true) + expect(useAppStore.getState().showNoteSearch).toBe(true) + }) +}) diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index f05a5c2..d7a33d4 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' - +import { SETTINGS_KEYS } from '../lib/settingsKeys' export interface SettingsState { themePreset: string fontFamily: string @@ -27,60 +27,60 @@ export interface SettingsState { } export const useSettingsStore = create((set) => ({ - themePreset: localStorage.getItem('papercache-theme-preset') || 'grid-light', - fontFamily: localStorage.getItem('papercache-font') || 'monospace', - showRulings: localStorage.getItem('papercache-rulings') !== 'false', - bgType: (localStorage.getItem('papercache-bg-type') as 'color' | 'image') || 'color', - bgColor: localStorage.getItem('papercache-bg-color') || '#ffffff', - bgImage: localStorage.getItem('papercache-bg-image') || '', - textColor: localStorage.getItem('papercache-text-color') || '#000000', - numColor: localStorage.getItem('papercache-color-num') || '#8ab4f8', - symColor: localStorage.getItem('papercache-sym-color') || '#ff0000', - aiColor: localStorage.getItem('papercache-ai-color') || '#8b5cf6', - mathColor: localStorage.getItem('papercache-math-color') || '#10b981', + themePreset: localStorage.getItem(SETTINGS_KEYS.THEME_PRESET) || 'grid-light', + fontFamily: localStorage.getItem(SETTINGS_KEYS.FONT_FAMILY) || "'JetBrains Mono', monospace", + showRulings: localStorage.getItem(SETTINGS_KEYS.SHOW_RULINGS) !== 'false', + bgType: (localStorage.getItem(SETTINGS_KEYS.BG_TYPE) as 'color' | 'image') || 'color', + bgColor: localStorage.getItem(SETTINGS_KEYS.BG_COLOR) || '#ffffff', + bgImage: localStorage.getItem(SETTINGS_KEYS.BG_IMAGE) || '', + textColor: localStorage.getItem(SETTINGS_KEYS.TEXT_COLOR) || '#000000', + numColor: localStorage.getItem(SETTINGS_KEYS.NUM_COLOR) || '#8ab4f8', + symColor: localStorage.getItem(SETTINGS_KEYS.SYM_COLOR) || '#ff0000', + aiColor: localStorage.getItem(SETTINGS_KEYS.AI_COLOR) || '#8b5cf6', + mathColor: localStorage.getItem(SETTINGS_KEYS.MATH_COLOR) || '#10b981', setThemePreset: (themePreset) => { - localStorage.setItem('papercache-theme-preset', themePreset) + localStorage.setItem(SETTINGS_KEYS.THEME_PRESET, themePreset) set({ themePreset }) }, setFontFamily: (fontFamily) => { - localStorage.setItem('papercache-font', fontFamily) + localStorage.setItem(SETTINGS_KEYS.FONT_FAMILY, fontFamily) set({ fontFamily }) }, setShowRulings: (showRulings) => { - localStorage.setItem('papercache-rulings', String(showRulings)) + localStorage.setItem(SETTINGS_KEYS.SHOW_RULINGS, String(showRulings)) set({ showRulings }) }, setBgType: (bgType) => { - localStorage.setItem('papercache-bg-type', bgType) + localStorage.setItem(SETTINGS_KEYS.BG_TYPE, bgType) set({ bgType }) }, setBgColor: (bgColor) => { - localStorage.setItem('papercache-bg-color', bgColor) + localStorage.setItem(SETTINGS_KEYS.BG_COLOR, bgColor) set({ bgColor }) }, setBgImage: (bgImage) => { - localStorage.setItem('papercache-bg-image', bgImage) + localStorage.setItem(SETTINGS_KEYS.BG_IMAGE, bgImage) set({ bgImage }) }, setTextColor: (textColor) => { - localStorage.setItem('papercache-text-color', textColor) + localStorage.setItem(SETTINGS_KEYS.TEXT_COLOR, textColor) set({ textColor }) }, setNumColor: (numColor) => { - localStorage.setItem('papercache-color-num', numColor) + localStorage.setItem(SETTINGS_KEYS.NUM_COLOR, numColor) set({ numColor }) }, setSymColor: (symColor) => { - localStorage.setItem('papercache-sym-color', symColor) + localStorage.setItem(SETTINGS_KEYS.SYM_COLOR, symColor) set({ symColor }) }, setAiColor: (aiColor) => { - localStorage.setItem('papercache-ai-color', aiColor) + localStorage.setItem(SETTINGS_KEYS.AI_COLOR, aiColor) set({ aiColor }) }, setMathColor: (mathColor) => { - localStorage.setItem('papercache-math-color', mathColor) + localStorage.setItem(SETTINGS_KEYS.MATH_COLOR, mathColor) set({ mathColor }) }, })) diff --git a/src/types.d.ts b/src/types.d.ts index 098a6bc..e32aaf1 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -4,6 +4,12 @@ export interface ElectronAPI { saveNote: (id: string, content: string) => Promise deleteNote: (id: string) => Promise renameNote: (oldId: string, newId: string) => Promise + openAIChat: (args: { + model: string + messages: { role: string; content: string }[] + apiKey: string + baseURL: string + }) => Promise readNote: (id: string) => Promise exportNote: (filename: string, content: string) => Promise openSettings: () => void