= ({ notes, onClose, on
color: rem.done ? 'white' : 'transparent',
marginRight: '12px',
flexShrink: 0,
- cursor: 'pointer'
+ cursor: 'pointer',
}}
>
-
+
{rem.targetMs && (
-
- {isOverdue ? 'Overdue: ' : 'Due: '}
+
+ {isOverdue ? 'Overdue: ' : 'Due: '}
{new Date(rem.targetMs).toLocaleString([], {
- month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
})}
)}
diff --git a/src/hooks/useGlobalHotkey.ts b/src/hooks/useGlobalHotkey.ts
new file mode 100644
index 0000000..a973d08
--- /dev/null
+++ b/src/hooks/useGlobalHotkey.ts
@@ -0,0 +1,158 @@
+import { useEffect } from 'react'
+import { useAppStore } from '../store/useAppStore'
+
+export function useGlobalHotkey() {
+ const {
+ showMainActionMenu,
+ showNoteSearch,
+ showGraphView,
+ setShowMainActionMenu,
+ setShowNoteSearch,
+ setShowGraphView,
+ setShowRemindersView,
+ setZoomLevel,
+ setNotes,
+ setCurrentNoteIndex,
+ setNoteSearchQuery,
+ setSearchSelectedIndex,
+ } = useAppStore()
+
+ useEffect(() => {
+ const handleGlobalKeyDown = async (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (showMainActionMenu) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowMainActionMenu(false)
+ return
+ }
+ if (showNoteSearch) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowNoteSearch(false)
+ return
+ }
+ if (showGraphView) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowGraphView(false)
+ return
+ }
+ }
+
+ // Settings Shortcut
+ if (e.key.toLowerCase() === 's' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ window.electronAPI.openSettings()
+ }
+
+ // Graph View Shortcut
+ if (e.key.toLowerCase() === 'g' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowGraphView((prev) => !prev)
+ }
+
+ if (e.key === 'n' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ const id = Date.now() + '.md'
+ const newNote = { id, content: '', mtime: Date.now() }
+ setNotes((prev) => [newNote, ...prev])
+ setCurrentNoteIndex(0)
+ window.electronAPI.saveNote(id, '')
+ }
+
+ if (e.key === 'e' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ e.stopPropagation()
+ const { notes, currentNoteIndex } = useAppStore.getState()
+ const note = notes[currentNoteIndex]
+ if (note) {
+ const filename = note.id.replace(/\.md$/, '')
+ window.electronAPI.exportNote(filename, note.content)
+ }
+ }
+
+ if (e.key.toLowerCase() === 'p' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowNoteSearch(true)
+ setNoteSearchQuery('')
+ setSearchSelectedIndex(0)
+ }
+
+ if (e.key.toLowerCase() === 't' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowRemindersView(true)
+ }
+
+ if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ e.stopPropagation()
+ setShowMainActionMenu((prev) => !prev)
+ }
+
+ // Zoom Shortcuts
+ if ((e.metaKey || e.ctrlKey) && (e.key === '=' || e.key === '+' || e.key === '-')) {
+ e.preventDefault()
+ setZoomLevel((prev) => {
+ const newZoom = e.key === '-' ? Math.max(0.5, prev - 0.1) : Math.min(3, prev + 0.1)
+ localStorage.setItem('papercache-zoom', newZoom.toString())
+ return newZoom
+ })
+ }
+
+ if ((e.metaKey || e.ctrlKey) && e.key === '0') {
+ e.preventDefault()
+ setZoomLevel(1)
+ localStorage.setItem('papercache-zoom', '1')
+ }
+ }
+
+ // Sync global shortcut on load
+ const shortcut =
+ localStorage.getItem('papercache-shortcut-newnote') || 'CommandOrControl+Shift+N'
+ if (window.electronAPI.updateGlobalShortcut) {
+ window.electronAPI.updateGlobalShortcut('new-note', '', shortcut)
+ }
+ const toggleShortcut =
+ localStorage.getItem('papercache-shortcut-toggle') || 'CommandOrControl+Shift+C'
+ if (window.electronAPI.updateGlobalShortcut) {
+ window.electronAPI.updateGlobalShortcut('toggle', '', toggleShortcut)
+ }
+
+ // Listen for global new note shortcut
+ if (window.electronAPI.onTriggerNewNote) {
+ window.electronAPI.onTriggerNewNote(() => {
+ const id = Date.now() + '.md'
+ const initialNote = { id, content: '', mtime: Date.now() }
+ setNotes((prev) => [initialNote, ...prev])
+ window.electronAPI.saveNote(id, '')
+ setCurrentNoteIndex(0)
+ })
+ }
+
+ if (window.electronAPI.onTriggerTasks) {
+ window.electronAPI.onTriggerTasks(() => {
+ setShowRemindersView((prev) => !prev)
+ })
+ }
+
+ window.addEventListener('keydown', handleGlobalKeyDown, { capture: true })
+ return () => window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true })
+ }, [
+ showMainActionMenu,
+ showNoteSearch,
+ showGraphView,
+ setShowGraphView,
+ setShowMainActionMenu,
+ setShowNoteSearch,
+ setNoteSearchQuery,
+ setSearchSelectedIndex,
+ setShowRemindersView,
+ setNotes,
+ setCurrentNoteIndex,
+ setZoomLevel,
+ ])
+}
diff --git a/src/hooks/useNoteStorage.ts b/src/hooks/useNoteStorage.ts
new file mode 100644
index 0000000..734a099
--- /dev/null
+++ b/src/hooks/useNoteStorage.ts
@@ -0,0 +1,53 @@
+import { useEffect } from 'react'
+import { useAppStore, Note } from '../store/useAppStore'
+
+export function useNoteStorage() {
+ const { notes, setNotes, currentNoteIndex, setCurrentNoteIndex } = useAppStore()
+
+ // Load notes initially
+ useEffect(() => {
+ async function loadNotes() {
+ const loaded = await window.electronAPI.getNotes()
+ if (loaded.length > 0) {
+ setNotes(loaded)
+ const lastOpenNoteId = localStorage.getItem('papercache-last-open-note')
+ if (lastOpenNoteId) {
+ const idx = loaded.findIndex((n: Note) => n.id === lastOpenNoteId)
+ if (idx !== -1) {
+ setCurrentNoteIndex(idx)
+ }
+ }
+ }
+ }
+ loadNotes()
+ }, [setNotes, setCurrentNoteIndex])
+
+ // Save current note index to localStorage
+ useEffect(() => {
+ if (notes.length > 0 && currentNoteIndex >= 0 && currentNoteIndex < notes.length) {
+ localStorage.setItem('papercache-last-open-note', notes[currentNoteIndex].id)
+ }
+ }, [currentNoteIndex, notes])
+
+ // Listen to external open note events
+ useEffect(() => {
+ const handleOpenNote = (e: any) => {
+ let path = e.detail.path
+ if (!path.endsWith('.md')) path += '.md'
+
+ // We need the latest notes, so use useAppStore.getState()
+ const currentNotes = useAppStore.getState().notes
+ const index = currentNotes.findIndex((n) => n.id === path)
+ if (index !== -1) {
+ setCurrentNoteIndex(index)
+ } else {
+ const newNote = { id: path, content: '', mtime: Date.now() }
+ window.electronAPI.saveNote(path, '')
+ setNotes([newNote, ...currentNotes])
+ setCurrentNoteIndex(0)
+ }
+ }
+ window.addEventListener('open-papercache-note', handleOpenNote)
+ return () => window.removeEventListener('open-papercache-note', handleOpenNote)
+ }, [setNotes, setCurrentNoteIndex])
+}
diff --git a/src/hooks/useReminders.ts b/src/hooks/useReminders.ts
new file mode 100644
index 0000000..85b74d1
--- /dev/null
+++ b/src/hooks/useReminders.ts
@@ -0,0 +1,50 @@
+import { useEffect } from 'react'
+import { useAppStore } from '../store/useAppStore'
+
+export function useReminders() {
+ const notes = useAppStore((state) => state.notes)
+
+ useEffect(() => {
+ if (Notification.permission !== 'granted' && Notification.permission !== 'denied') {
+ Notification.requestPermission()
+ }
+
+ const interval = setInterval(() => {
+ const notifiedStr = localStorage.getItem('papercache_notified') || '[]'
+ const notified = new Set
(JSON.parse(notifiedStr))
+ let hasNewNotifs = false
+
+ notes.forEach((note) => {
+ const reRem =
+ /\/(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]*$/gm
+ let match
+ while ((match = reRem.exec(note.content)) !== null) {
+ const isDone = match[1] === 'task-done'
+ const label = match[3]
+ const targetStr = match[4]
+ if (!isDone && targetStr) {
+ const targetMs = new Date(targetStr).getTime()
+ if (Date.now() >= targetMs) {
+ const notifKey = `${note.id}-${targetMs}-${label}`
+ if (!notified.has(notifKey)) {
+ console.log('Triggering OS notification for:', label)
+ new Notification('PaperCache Reminder', {
+ body: label,
+ silent: false,
+ })
+ notified.add(notifKey)
+ hasNewNotifs = true
+ }
+ }
+ }
+ }
+ })
+
+ if (hasNewNotifs) {
+ localStorage.setItem('papercache_notified', JSON.stringify(Array.from(notified)))
+ }
+ }, 10000)
+
+ return () => clearInterval(interval)
+ }, [notes])
+}
diff --git a/src/hooks/useVariables.ts b/src/hooks/useVariables.ts
new file mode 100644
index 0000000..0651297
--- /dev/null
+++ b/src/hooks/useVariables.ts
@@ -0,0 +1,25 @@
+import { useEffect } from 'react'
+import * as mathjs from 'mathjs'
+import { useAppStore } from '../store/useAppStore'
+
+export function useVariables() {
+ const notes = useAppStore((state) => state.notes)
+
+ // Sync global variables whenever notes change
+ useEffect(() => {
+ const globals: any = {}
+ const reVar = /^\/globvar\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm
+ notes.forEach((note) => {
+ let varMatch
+ while ((varMatch = reVar.exec(note.content)) !== null) {
+ const name = varMatch[1]
+ try {
+ globals[name] = mathjs.evaluate(varMatch[2], globals)
+ } catch {
+ globals[name] = varMatch[2].trim()
+ }
+ }
+ })
+ ;(window as any).__globalVariables = globals
+ }, [notes])
+}
diff --git a/src/lib/editor/matchers.ts b/src/lib/editor/matchers.ts
new file mode 100644
index 0000000..a032a16
--- /dev/null
+++ b/src/lib/editor/matchers.ts
@@ -0,0 +1,41 @@
+import { HighlightStyle } from '@codemirror/language'
+import { tags as t } from '@lezer/highlight'
+import { MatchDecorator, Decoration } from '@codemirror/view'
+
+export const mdHighlighting = HighlightStyle.define([
+ { tag: t.heading1, fontSize: '1.4em', fontWeight: 'bold' },
+ { tag: t.heading2, fontSize: '1.2em', fontWeight: 'bold' },
+ { tag: t.heading3, fontSize: '1.1em', fontWeight: 'bold' },
+ { tag: t.heading4, fontSize: '1em', fontWeight: 'bold' },
+ { tag: t.heading5, fontSize: '1em', fontWeight: 'bold' },
+ { tag: t.heading6, fontSize: '1em', fontWeight: 'bold' },
+ { tag: t.strong, fontWeight: 'bold' },
+ { tag: t.emphasis, fontStyle: 'italic' },
+ { tag: t.strikethrough, textDecoration: 'line-through' },
+ { tag: t.link, color: '#3b82f6', textDecoration: 'underline' },
+ { tag: t.url, color: '#3b82f6' },
+ { tag: t.processingInstruction, color: 'rgba(128,128,128,0.5)' },
+ { tag: t.meta, color: 'rgba(128,128,128,0.5)' },
+ { tag: t.punctuation, color: 'rgba(128,128,128,0.5)' },
+])
+
+// Custom Decorators for syntax highlighting
+export const numberMatcher = new MatchDecorator({
+ regexp: /\b\d+(\.\d+)?\b/g,
+ decoration: Decoration.mark({ class: 'cm-custom-number' }),
+})
+
+export const symbolMatcher = new MatchDecorator({
+ regexp: /[+\-*/=^()]/g,
+ decoration: Decoration.mark({ class: 'cm-custom-symbol' }),
+})
+
+export const aiMatcher = new MatchDecorator({
+ regexp: /\u200B[\s\S]*?\u200C/g,
+ decoration: Decoration.mark({ class: 'cm-custom-ai' }),
+})
+
+export const mathMatcher = new MatchDecorator({
+ regexp: /\u200B.*/g, // matches zero-width space and everything after it
+ decoration: Decoration.mark({ class: 'cm-custom-math' }),
+})
diff --git a/src/lib/editor/plugins.ts b/src/lib/editor/plugins.ts
new file mode 100644
index 0000000..4e33c8a
--- /dev/null
+++ b/src/lib/editor/plugins.ts
@@ -0,0 +1,611 @@
+import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@codemirror/view'
+import { syntaxTree } from '@codemirror/language'
+import * as mathjs from 'mathjs'
+import { numberMatcher, symbolMatcher, aiMatcher, mathMatcher } from './matchers'
+import { CopyWidget, CheckboxWidget, VariableWidget, ReminderWidget } from './widgets'
+
+export const numberPlugin = ViewPlugin.fromClass(
+ class {
+ decorations
+ constructor(view: EditorView) {
+ this.decorations = numberMatcher.createDeco(view)
+ }
+ update(update: ViewUpdate) {
+ this.decorations = numberMatcher.updateDeco(update, this.decorations)
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
+
+export const symbolPlugin = ViewPlugin.fromClass(
+ class {
+ decorations
+ constructor(view: EditorView) {
+ this.decorations = symbolMatcher.createDeco(view)
+ }
+ update(update: ViewUpdate) {
+ this.decorations = symbolMatcher.updateDeco(update, this.decorations)
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
+
+export const aiPlugin = ViewPlugin.fromClass(
+ class {
+ decorations
+ constructor(view: EditorView) {
+ this.decorations = aiMatcher.createDeco(view)
+ }
+ update(update: ViewUpdate) {
+ this.decorations = aiMatcher.updateDeco(update, this.decorations)
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
+
+export const mathPlugin = ViewPlugin.fromClass(
+ class {
+ decorations
+ constructor(view: EditorView) {
+ this.decorations = mathMatcher.createDeco(view)
+ }
+ update(update: ViewUpdate) {
+ this.decorations = mathMatcher.updateDeco(update, this.decorations)
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
+
+export const hideMarkdownPlugin = 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: any) => r.from <= end && r.to >= start)
+ }
+
+ const linkRanges: { from: number; to: number }[] = []
+ const fullDoc = view.state.doc.toString()
+
+ // Build variable scope (incorporate global variables)
+ const scope: any = Object.assign({}, (window as any).__globalVariables || {})
+ const reVar = /^\/var\s+([a-zA-Z0-9_]+)\s*=\s*(.*)$/gm
+ let varMatch
+ while ((varMatch = reVar.exec(fullDoc)) !== null) {
+ const name = varMatch[1]
+ try {
+ scope[name] = mathjs.evaluate(varMatch[2], scope)
+ } catch {
+ scope[name] = varMatch[2].trim()
+ }
+ }
+ const scopeKeys = Object.keys(scope).sort((a, b) => b.length - a.length)
+
+ 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(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.mark({
+ class: 'cm-color-pill',
+ attributes: { style: `--pill-color: ${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)
+ const reDate = /\b\d{4}-\d{2}-\d{2}\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' }),
+ })
+ }
+ }
+ }
+
+ // 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: any = null
+ let endCodeMark: any = null
+ let codeInfo: any = 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 {
+ 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)
+ }
+ }
+ }
+)
diff --git a/src/lib/editor/widgets.ts b/src/lib/editor/widgets.ts
new file mode 100644
index 0000000..d545a89
--- /dev/null
+++ b/src/lib/editor/widgets.ts
@@ -0,0 +1,160 @@
+import { WidgetType, EditorView } from '@codemirror/view'
+
+export class CopyWidget extends WidgetType {
+ code: string
+ language: string
+ constructor(code: string, language: string) {
+ super()
+ this.code = code
+ this.language = language
+ }
+
+ eq(other: CopyWidget) {
+ return other.code === this.code && other.language === this.language
+ }
+
+ toDOM() {
+ const wrap = document.createElement('span')
+ wrap.setAttribute('aria-hidden', 'true')
+ wrap.className = 'cm-copy-button'
+ wrap.title = 'Copy code'
+
+ if (this.language) {
+ const langSpan = document.createElement('sup')
+ langSpan.textContent = this.language
+ langSpan.className = 'cm-code-lang'
+ wrap.appendChild(langSpan)
+ }
+
+ const iconSpan = document.createElement('span')
+ // Standard copy icon (two offset rounded rectangles)
+ iconSpan.innerHTML = ``
+ wrap.appendChild(iconSpan)
+
+ wrap.onclick = (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ navigator.clipboard.writeText(this.code)
+ const originalHtml = iconSpan.innerHTML
+ // Checkmark icon
+ iconSpan.innerHTML = ``
+ setTimeout(() => {
+ iconSpan.innerHTML = originalHtml
+ }, 2000)
+ }
+ return wrap
+ }
+}
+
+export class CheckboxWidget extends WidgetType {
+ checked: boolean
+ pos: number
+ view: EditorView
+
+ constructor(checked: boolean, pos: number, view: EditorView) {
+ super()
+ this.checked = checked
+ this.pos = pos
+ this.view = view
+ }
+
+ eq(other: CheckboxWidget) {
+ return other.checked === this.checked && other.pos === this.pos
+ }
+
+ toDOM() {
+ const wrap = document.createElement('span')
+ wrap.className = 'cm-checkbox-widget' + (this.checked ? ' cm-checkbox-checked' : '')
+
+ if (this.checked) {
+ wrap.innerHTML = ``
+ } else {
+ wrap.innerHTML = `` // empty for unchecked, border provides the box
+ }
+
+ wrap.onclick = (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const from = this.pos
+ const to = this.pos + (this.checked ? 8 : 6) // length of "/checked" or "/check"
+ const insert = this.checked ? '/check' : '/checked'
+ this.view.dispatch({
+ changes: { from, to, insert },
+ })
+ }
+
+ return wrap
+ }
+}
+
+export class VariableWidget extends WidgetType {
+ value: string
+ constructor(value: string) {
+ super()
+ this.value = value
+ }
+ eq(other: VariableWidget) {
+ return other.value === this.value
+ }
+ toDOM() {
+ const span = document.createElement('span')
+ span.textContent = String(this.value)
+ span.className = 'cm-variable-pill'
+ return span
+ }
+}
+
+export class ReminderWidget extends WidgetType {
+ checked: boolean
+ overdue: boolean
+ pos: number
+ view: EditorView
+
+ constructor(checked: boolean, overdue: boolean, pos: number, view: EditorView) {
+ super()
+ this.checked = checked
+ this.overdue = overdue
+ this.pos = pos
+ this.view = view
+ }
+
+ eq(other: ReminderWidget) {
+ return (
+ other.checked === this.checked && other.pos === this.pos && other.overdue === this.overdue
+ )
+ }
+
+ toDOM() {
+ const wrap = document.createElement('span')
+ wrap.className =
+ 'cm-rem-widget' +
+ (this.checked ? ' cm-rem-checked' : '') +
+ (this.overdue && !this.checked ? ' cm-rem-overdue' : '')
+
+ if (this.checked) {
+ wrap.innerHTML = ``
+ } else {
+ wrap.innerHTML = `` // empty for unchecked, border provides the box
+ }
+
+ // Use onmousedown to prevent CodeMirror from interfering with selection
+ wrap.onmousedown = (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ const from = this.pos
+ const to = this.pos + (this.checked ? 10 : 5) // length of "/task-done" or "/task"
+ const insert = this.checked ? '/task' : '/task-done'
+
+ this.view.dispatch({
+ changes: { from, to, insert },
+ })
+ }
+
+ return wrap
+ }
+
+ ignoreEvent() {
+ return true
+ }
+}
diff --git a/src/lib/safeStorage.ts b/src/lib/safeStorage.ts
new file mode 100644
index 0000000..3093ff1
--- /dev/null
+++ b/src/lib/safeStorage.ts
@@ -0,0 +1,18 @@
+export async function setSecure(key: string, value: string): Promise {
+ const encrypted = await window.electronAPI.safeStorageEncrypt(value)
+ localStorage.setItem(`${key}-secure`, encrypted)
+}
+
+export async function getSecure(key: string): Promise {
+ const encrypted = localStorage.getItem(`${key}-secure`)
+ if (!encrypted) return null
+ return await window.electronAPI.safeStorageDecrypt(encrypted)
+}
+
+export async function migrateApiKeyFromLocalStorage(key: string) {
+ const plain = localStorage.getItem(key)
+ if (plain) {
+ await setSecure(key, plain)
+ localStorage.removeItem(key)
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
index 58f9d3a..463c37d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import Settings from './Settings.tsx'
+import { migrateApiKeyFromLocalStorage } from './lib/safeStorage'
function Root() {
const [hash, setHash] = useState(window.location.hash)
+ const [migrated, setMigrated] = useState(false)
useEffect(() => {
const handleHashChange = () => setHash(window.location.hash)
@@ -13,6 +15,16 @@ function Root() {
return () => window.removeEventListener('hashchange', handleHashChange)
}, [])
+ useEffect(() => {
+ async function migrate() {
+ await migrateApiKeyFromLocalStorage('papercache-apikey')
+ setMigrated(true)
+ }
+ migrate()
+ }, [])
+
+ if (!migrated) return null
+
return {hash === '#/settings' ? : }
}
diff --git a/src/store/useAIStore.ts b/src/store/useAIStore.ts
new file mode 100644
index 0000000..f04d966
--- /dev/null
+++ b/src/store/useAIStore.ts
@@ -0,0 +1,36 @@
+import { create } from 'zustand'
+
+export interface AIState {
+ apiKey: string
+ apiBaseUrl: string
+ apiModel: string
+ aiSystemPrompt: string
+
+ setApiKey: (key: string) => void
+ setApiBaseUrl: (url: string) => void
+ setApiModel: (model: string) => void
+ setAiSystemPrompt: (prompt: string) => void
+}
+
+export const useAIStore = create((set) => ({
+ apiKey: '',
+ apiBaseUrl: localStorage.getItem('papercache-api-base-url') || 'https://api.openai.com/v1',
+ apiModel: localStorage.getItem('papercache-api-model') || 'gpt-4o',
+ aiSystemPrompt:
+ localStorage.getItem('papercache-ai-system-prompt') ||
+ 'You are a helpful assistant directly inside a markdown note. You can format your responses with markdown.',
+
+ setApiKey: (apiKey) => set({ apiKey }),
+ setApiBaseUrl: (apiBaseUrl) => {
+ localStorage.setItem('papercache-api-base-url', apiBaseUrl)
+ set({ apiBaseUrl })
+ },
+ setApiModel: (apiModel) => {
+ localStorage.setItem('papercache-api-model', apiModel)
+ set({ apiModel })
+ },
+ setAiSystemPrompt: (aiSystemPrompt) => {
+ localStorage.setItem('papercache-ai-system-prompt', aiSystemPrompt)
+ set({ aiSystemPrompt })
+ },
+}))
diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts
new file mode 100644
index 0000000..8ae9077
--- /dev/null
+++ b/src/store/useAppStore.ts
@@ -0,0 +1,106 @@
+import { create } from 'zustand'
+
+export interface Note {
+ id: string
+ content: string
+ mtime: number
+}
+
+interface AppState {
+ notes: Note[]
+ currentNoteIndex: number
+ zoomLevel: number
+
+ // UI state
+ showGraphView: boolean
+ showRemindersView: boolean
+ isRenaming: boolean
+ renameValue: string
+ showNoteSearch: boolean
+ noteSearchQuery: string
+ searchSelectedIndex: number
+ showNoteActionMenu: boolean
+ showMainActionMenu: boolean
+ actionMenuIndex: number
+
+ setNotes: (notes: Note[] | ((prev: Note[]) => Note[])) => void
+ setCurrentNoteIndex: (index: number) => void
+ setZoomLevel: (zoom: number | ((prev: number) => number)) => void
+
+ setShowGraphView: (show: boolean | ((prev: boolean) => boolean)) => void
+ setShowRemindersView: (show: boolean | ((prev: boolean) => boolean)) => void
+ setIsRenaming: (isRenaming: boolean) => void
+ setRenameValue: (renameValue: string) => void
+ setShowNoteSearch: (show: boolean) => void
+ setNoteSearchQuery: (query: string) => void
+ setSearchSelectedIndex: (index: number | ((prev: number) => number)) => void
+ setShowNoteActionMenu: (show: boolean) => void
+ setShowMainActionMenu: (show: boolean | ((prev: boolean) => boolean)) => void
+ setActionMenuIndex: (index: number | ((prev: number) => number)) => void
+}
+
+export const useAppStore = create((set) => ({
+ notes: [],
+ currentNoteIndex: 0,
+ zoomLevel: Number(localStorage.getItem('papercache-zoom')) || 1,
+
+ showGraphView: false,
+ showRemindersView: false,
+ isRenaming: false,
+ renameValue: '',
+ showNoteSearch: false,
+ noteSearchQuery: '',
+ searchSelectedIndex: 0,
+ showNoteActionMenu: false,
+ showMainActionMenu: false,
+ actionMenuIndex: 0,
+
+ setNotes: (notes) =>
+ set((state) => ({
+ notes: typeof notes === 'function' ? notes(state.notes) : notes,
+ })),
+ setCurrentNoteIndex: (currentNoteIndex) => set({ currentNoteIndex }),
+ setZoomLevel: (zoomLevel) =>
+ set((state) => ({
+ zoomLevel: typeof zoomLevel === 'function' ? zoomLevel(state.zoomLevel) : zoomLevel,
+ })),
+
+ setShowGraphView: (showGraphView) =>
+ set((state) => ({
+ showGraphView:
+ typeof showGraphView === 'function' ? showGraphView(state.showGraphView) : showGraphView,
+ })),
+ setShowRemindersView: (showRemindersView) =>
+ set((state) => ({
+ showRemindersView:
+ typeof showRemindersView === 'function'
+ ? showRemindersView(state.showRemindersView)
+ : showRemindersView,
+ })),
+ setIsRenaming: (isRenaming) => set({ isRenaming }),
+ setRenameValue: (renameValue) => set({ renameValue }),
+ setShowNoteSearch: (showNoteSearch) => set({ showNoteSearch }),
+ setNoteSearchQuery: (noteSearchQuery) => set({ noteSearchQuery }),
+ setSearchSelectedIndex: (searchSelectedIndex) =>
+ set((state) => ({
+ searchSelectedIndex:
+ typeof searchSelectedIndex === 'function'
+ ? searchSelectedIndex(state.searchSelectedIndex)
+ : searchSelectedIndex,
+ })),
+ setShowNoteActionMenu: (showNoteActionMenu) => set({ showNoteActionMenu }),
+ setShowMainActionMenu: (showMainActionMenu) =>
+ set((state) => ({
+ showMainActionMenu:
+ typeof showMainActionMenu === 'function'
+ ? showMainActionMenu(state.showMainActionMenu)
+ : showMainActionMenu,
+ })),
+ setActionMenuIndex: (actionMenuIndex) =>
+ set((state) => ({
+ actionMenuIndex:
+ typeof actionMenuIndex === 'function'
+ ? actionMenuIndex(state.actionMenuIndex)
+ : actionMenuIndex,
+ })),
+}))
diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts
new file mode 100644
index 0000000..9b32f82
--- /dev/null
+++ b/src/store/useSettingsStore.ts
@@ -0,0 +1,86 @@
+import { create } from 'zustand'
+
+export interface SettingsState {
+ themePreset: string
+ fontFamily: string
+ showRulings: boolean
+ bgType: 'color' | 'image'
+ bgColor: string
+ bgImage: string
+ textColor: string
+ numColor: string
+ symColor: string
+ aiColor: string
+ mathColor: string
+
+ setThemePreset: (preset: string) => void
+ setFontFamily: (font: string) => void
+ setShowRulings: (show: boolean) => void
+ setBgType: (type: 'color' | 'image') => void
+ setBgColor: (color: string) => void
+ setBgImage: (image: string) => void
+ setTextColor: (color: string) => void
+ setNumColor: (color: string) => void
+ setSymColor: (color: string) => void
+ setAiColor: (color: string) => void
+ setMathColor: (color: string) => void
+}
+
+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-num-color') || '#0000ff',
+ symColor: localStorage.getItem('papercache-sym-color') || '#ff0000',
+ aiColor: localStorage.getItem('papercache-ai-color') || '#8b5cf6',
+ mathColor: localStorage.getItem('papercache-math-color') || '#10b981',
+
+ setThemePreset: (themePreset) => {
+ localStorage.setItem('papercache-theme-preset', themePreset)
+ set({ themePreset })
+ },
+ setFontFamily: (fontFamily) => {
+ localStorage.setItem('papercache-font', fontFamily)
+ set({ fontFamily })
+ },
+ setShowRulings: (showRulings) => {
+ localStorage.setItem('papercache-rulings', String(showRulings))
+ set({ showRulings })
+ },
+ setBgType: (bgType) => {
+ localStorage.setItem('papercache-bg-type', bgType)
+ set({ bgType })
+ },
+ setBgColor: (bgColor) => {
+ localStorage.setItem('papercache-bg-color', bgColor)
+ set({ bgColor })
+ },
+ setBgImage: (bgImage) => {
+ localStorage.setItem('papercache-bg-image', bgImage)
+ set({ bgImage })
+ },
+ setTextColor: (textColor) => {
+ localStorage.setItem('papercache-text-color', textColor)
+ set({ textColor })
+ },
+ setNumColor: (numColor) => {
+ localStorage.setItem('papercache-num-color', numColor)
+ set({ numColor })
+ },
+ setSymColor: (symColor) => {
+ localStorage.setItem('papercache-sym-color', symColor)
+ set({ symColor })
+ },
+ setAiColor: (aiColor) => {
+ localStorage.setItem('papercache-ai-color', aiColor)
+ set({ aiColor })
+ },
+ setMathColor: (mathColor) => {
+ localStorage.setItem('papercache-math-color', mathColor)
+ set({ mathColor })
+ },
+}))
diff --git a/src/types.d.ts b/src/types.d.ts
new file mode 100644
index 0000000..f474006
--- /dev/null
+++ b/src/types.d.ts
@@ -0,0 +1,26 @@
+export interface ElectronAPI {
+ closeWindow: () => Promise
+ getNotes: () => Promise
+ saveNote: (id: string, content: string) => Promise
+ deleteNote: (id: string) => Promise
+ renameNote: (oldId: string, newId: string) => Promise
+ readNote: (id: string) => Promise
+ exportNote: (filename: string, content: string) => Promise
+ openSettings: () => void
+ quitApp: () => void
+ openExternal: (url: string) => void
+ openFile: (path: string) => void
+ onSwipeGesture: (callback: (direction: string) => void) => void
+ setLaunchAtStartup: (value: boolean) => void
+ updateGlobalShortcut: (action: string, oldShortcut: string, newShortcut: string) => void
+ onTriggerNewNote: (callback: () => void) => void
+ onTriggerTasks: (callback: () => void) => void
+ safeStorageEncrypt: (val: string) => Promise
+ safeStorageDecrypt: (val: string) => Promise
+}
+
+declare global {
+ interface Window {
+ electronAPI: ElectronAPI
+ }
+}
diff --git a/src/utils.ts b/src/utils.ts
index 50302e9..5758e31 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -3,7 +3,7 @@ export const getFolderColor = (str: string): string => {
let colors: Record = {}
try {
colors = JSON.parse(localStorage.getItem('papercache-folder-colors') || '{}')
- } catch (e) {}
+ } catch {}
if (colors[str]) return colors[str]
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 7f42e5f..c095c0a 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -6,6 +6,10 @@
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitReturns": true,
+ "exactOptionalPropertyTypes": true,
/* Bundler mode */
"moduleResolution": "bundler",