diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 413c085..e9058e7 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -3,6 +3,12 @@ use std::path::PathBuf; use std::sync::Mutex; use tauri::Manager; +/// Column projection used by every SELECT on the `notes` table. +/// +/// Order must match the indices expected by [`note_from_row`]. +const NOTE_COLUMNS: &str = + "id, title, content, created_at, updated_at, is_pinned, group_id, is_locked"; + /// Application-level wrapper around a mutex-guarded SQLite connection. /// /// Registered as Tauri managed state via [`init_db`] so that every @@ -35,12 +41,14 @@ pub struct Note { pub is_pinned: bool, /// The UUID of the group this note belongs to, or `None` for uncategorized. pub group_id: Option, + /// Whether the note is locked (read-only). + pub is_locked: bool, } /// Maps a single result row from the `notes` table to a [`Note`] struct. /// /// Column order must match the projection used in every SELECT that calls -/// this function: `id, title, content, created_at, updated_at, is_pinned`. +/// this function: `id, title, content, created_at, updated_at, is_pinned, group_id, is_locked`. pub(crate) fn note_from_row(row: &rusqlite::Row) -> Result { Ok(Note { id: row.get(0)?, @@ -50,6 +58,7 @@ pub(crate) fn note_from_row(row: &rusqlite::Row) -> Result Result<(), String> { [], ); + // Migration: add is_locked column if it does not exist. + let _ = conn.execute( + "ALTER TABLE notes ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0", + [], + ); + app.manage(DbState(Mutex::new(conn))); Ok(()) } @@ -145,9 +160,7 @@ pub fn init_db(app: &tauri::AppHandle) -> Result<(), String> { pub fn get_note(state: tauri::State, id: String) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; let mut stmt = conn - .prepare( - "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1", - ) + .prepare(&format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1")) .map_err(|e| e.to_string())?; let note = stmt.query_row([&id], note_from_row).ok(); @@ -175,9 +188,9 @@ pub fn get_note(state: tauri::State, id: String) -> Result pub fn list_notes(state: tauri::State) -> Result, String> { let conn = state.0.lock().map_err(|e| e.to_string())?; let mut stmt = conn - .prepare( - "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes ORDER BY is_pinned DESC, updated_at DESC", - ) + .prepare(&format!( + "SELECT {NOTE_COLUMNS} FROM notes ORDER BY is_pinned DESC, updated_at DESC" + )) .map_err(|e| e.to_string())?; let notes = stmt @@ -216,7 +229,7 @@ pub fn create_note( let now = chrono::Utc::now().to_rfc3339(); conn.execute( - "INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id) VALUES (?1, ?2, ?3, ?4, ?5, 0, NULL)", + "INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id, is_locked) VALUES (?1, ?2, ?3, ?4, ?5, 0, NULL, 0)", rusqlite::params![id, title, content, now, now], ) .map_err(|e| e.to_string())?; @@ -229,6 +242,7 @@ pub fn create_note( updated_at: now, is_pinned: false, group_id: None, + is_locked: false, }) } @@ -270,7 +284,7 @@ pub fn update_note( let note = conn .query_row( - "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1", + &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), rusqlite::params![id], note_from_row, ) @@ -307,7 +321,7 @@ pub fn toggle_pin(state: tauri::State, id: String, pinned: bool) -> Res let note = conn .query_row( - "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1", + &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), rusqlite::params![id], note_from_row, ) @@ -359,7 +373,7 @@ pub fn duplicate_note(state: tauri::State, id: String) -> Result, id: String) -> Result, id: String) -> Result, id: String, locked: bool) -> Result { + let conn = state.0.lock().map_err(|e| e.to_string())?; + + conn.execute( + "UPDATE notes SET is_locked = ?1, updated_at = datetime('now') WHERE id = ?2", + rusqlite::params![locked, id], + ) + .map_err(|e| e.to_string())?; + + let note = conn + .query_row( + &format!("SELECT {NOTE_COLUMNS} FROM notes WHERE id = ?1"), + rusqlite::params![id], + note_from_row, + ) + .map_err(|e| e.to_string())?; + + Ok(note) +} + /// Rewrites the text of the first heading block in a BlockNote content JSON. /// /// BlockNote content is a JSON array of block objects. Each block may have a diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b695695..3cbdb2c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -61,6 +61,7 @@ pub fn run() { db::delete_note, db::duplicate_note, db::toggle_pin, + db::toggle_lock, groups::list_groups, groups::create_group, groups::rename_group, diff --git a/src/App.tsx b/src/App.tsx index 361ac18..9df9966 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { getNote, listNotes, readTextFile, + toggleLockNote, togglePinNote, useCommandPaletteScroll, useCursorAutoHideEffect, @@ -78,6 +79,7 @@ function AppContent() { const [sidebarOpen, setSidebarOpen] = useState(configDefaults.sidebarOpen) const [refreshKey, setRefreshKey] = useState(0) const [saveStatus, setSaveStatus] = useState('idle') + const [isNoteLocked, setIsNoteLocked] = useState(false) const editorRef = useRef(null) const scrollContainerRef = useRef(null) const { isHidden: isHeaderHidden } = useScrollDirection(scrollContainerRef, { @@ -230,6 +232,7 @@ function AppContent() { saveCursorPosition(selectedNoteId) } setSelectedNoteId(id) + setIsNoteLocked(false) persistLastNoteId(id) }, [selectedNoteId, saveScrollPosition, saveCursorPosition, persistLastNoteId] @@ -271,6 +274,15 @@ function AppContent() { setRefreshKey((v) => v + 1) }, []) + /** + * Callback invoked when the lock state of the loaded note is determined. + * + * @param locked - `true` if the note is locked, `false` otherwise. + */ + const handleLockStateChange = useCallback((locked: boolean) => { + setIsNoteLocked(locked) + }, []) + /** * Creates a new note with default content and selects it. * @@ -332,6 +344,28 @@ function AppContent() { [] ) + /** + * Toggles the locked state of the given note. + * + * @param noteId - The ID of the note whose lock state should be toggled. + * @param locked - The new locked state to apply. + * @throws Shows an error toast if the toggle operation fails. + */ + const handleToggleLock = useCallback( + async (noteId: string, locked: boolean) => { + try { + await toggleLockNote(noteId, locked) + if (selectedNoteId === noteId) { + setIsNoteLocked(locked) + } + setRefreshKey((v) => v + 1) + } catch { + toast.error('Failed to toggle lock') + } + }, + [selectedNoteId] + ) + /** * Duplicates the specified note and selects the newly created copy. * @@ -429,6 +463,7 @@ function AppContent() { onNewNote={handleNewNote} onDeleteNote={handleDeleteNote} onTogglePin={handleTogglePin} + onToggleLock={handleToggleLock} onDuplicateNote={handleDuplicateNote} onExportNote={handleExportNote} onImportNote={handleImportNote} @@ -453,16 +488,18 @@ function AppContent() { className="custom-scrollbar flex-1 overflow-y-auto overscroll-none" >
- +
('toggle_pin', { id, pinned }) } +/** + * Toggles the locked (read-only) state of a note. + * + * @param id - The UUID of the note to lock or unlock. + * @param locked - `true` to lock, `false` to unlock. + * @returns The updated note as it exists in the database after the write. + */ +export async function toggleLockNote( + id: string, + locked: boolean +): Promise { + return invoke('toggle_lock', { id, locked }) +} + /** * Duplicates an existing note. * diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts index c00dd4c..8bd51b0 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -13,6 +13,7 @@ export { getNote, listNotes, readTextFile, + toggleLockNote, togglePinNote, updateNote, writeTextFile, diff --git a/src/features/editor/lib/readOnlyGuard.ts b/src/features/editor/lib/readOnlyGuard.ts new file mode 100644 index 0000000..91a1c74 --- /dev/null +++ b/src/features/editor/lib/readOnlyGuard.ts @@ -0,0 +1,51 @@ +import { createExtension } from '@blocknote/core' +import { Plugin, PluginKey } from 'prosemirror-state' + +/** + * Plugin key used to identify the read-only guard plugin. + */ +const PLUGIN_KEY = new PluginKey('readOnlyGuard') + +/** + * Module-level flag that controls whether the editor should block all + * editing transactions. Set by the React component via {@link setReadOnly}. + * + * Using a module-level variable (instead of React state) allows the + * ProseMirror plugin's `filterTransaction` to read it synchronously + * without requiring the plugin to be recreated on every state change. + */ +let readOnly = false + +/** + * Updates the read-only flag. Called from the Editor React component + * whenever the `locked` prop changes. + */ +export function setReadOnly(value: boolean): void { + readOnly = value +} + +/** + * BlockNote extension that blocks all editing transactions when the + * read-only flag is `true`. + * + * Transactions with `addToHistory === false` meta are allowed through so + * that programmatic content loads (e.g. `replaceBlocks` during note + * switching) still work. Selection-only transactions (cursor movement) + * are also allowed so the user can position the cursor for copying. + */ +export const readOnlyGuardExtension = createExtension({ + key: 'readOnlyGuard', + prosemirrorPlugins: [ + new Plugin({ + key: PLUGIN_KEY, + filterTransaction: (tr) => { + if (!readOnly) return true + // Allow programmatic content loads that suppress history. + if (tr.getMeta('addToHistory') === false) return true + // Allow selection-only transactions (cursor movement, text selection). + if (tr.docChanged) return false + return true + }, + }), + ], +}) diff --git a/src/features/editor/ui/Editor.tsx b/src/features/editor/ui/Editor.tsx index 6ab27a7..82b6323 100644 --- a/src/features/editor/ui/Editor.tsx +++ b/src/features/editor/ui/Editor.tsx @@ -48,6 +48,7 @@ import { useSearchReplace } from '../hooks/useSearchReplace' import { codeBlockOptions } from '../lib/codeBlockConfig' import { DEFAULT_BLOCKS } from '../lib/constants' import { rangeCheckToggleExtension } from '../lib/rangeCheckToggle' +import { readOnlyGuardExtension, setReadOnly } from '../lib/readOnlyGuard' import { slashMenuEmacsKeysExtension } from '../lib/slashMenuEmacsKeys' import { CustomColorStyleButton } from './CustomColorStyleButton' import { CustomLinkToolbar } from './CustomLinkToolbar' @@ -137,11 +138,15 @@ const schema = BlockNoteSchema.create({ */ interface EditorProps { noteId: string | null + /** Whether the editor is in read-only mode. Defaults to `false`. */ + locked?: boolean onNoteSaved?: (id: string) => void onStatusChange?: (status: SaveStatus) => void onContentLoaded?: () => void /** Called with the cursor's clientY coordinate when the suggestion menu (slash command palette) opens. */ onSuggestionMenuOpen?: (cursorClientY: number) => void + /** Called when the lock state of the loaded note is determined. */ + onLockStateChange?: (locked: boolean) => void } /** @@ -202,10 +207,12 @@ const PASS_THROUGH_KEYS = new Set([ export const Editor = forwardRef(function Editor( { noteId, + locked = false, onNoteSaved, onStatusChange, onContentLoaded, onSuggestionMenuOpen, + onLockStateChange, }, ref ) { @@ -285,6 +292,7 @@ export const Editor = forwardRef(function Editor( searchExtension, checklistSplitFixExtension(), rangeCheckToggleExtension(), + readOnlyGuardExtension, slashMenuEmacsKeysExtension(), cursorVimKeysExtension(), ], @@ -294,6 +302,11 @@ export const Editor = forwardRef(function Editor( useImperativeHandle(ref, () => ({ editor }), [editor]) + // Sync the locked prop to the module-level flag read by the ProseMirror plugin. + useEffect(() => { + setReadOnly(locked) + }, [locked]) + useLinkClickHandler(editor) useCopyToast(editor) // Rewrite clipboard text/plain so Markdown lists are tight (no blank lines between items). @@ -462,6 +475,7 @@ export const Editor = forwardRef(function Editor( loadingRef.current = false setContentReady(true) onContentLoaded?.() + onLockStateChange?.(false) } }) return @@ -471,6 +485,7 @@ export const Editor = forwardRef(function Editor( .then((note) => { if (stale) return if (note) { + onLockStateChange?.(note.isLocked) withSuppressedHistory(editor.prosemirrorView, () => { try { editor.replaceBlocks( @@ -500,7 +515,13 @@ export const Editor = forwardRef(function Editor( return () => { stale = true } - }, [noteId, editor, backfillImageCaptions, onContentLoaded]) + }, [ + noteId, + editor, + backfillImageCaptions, + onContentLoaded, + onLockStateChange, + ]) /** * Callback invoked by BlockNote on every document change. @@ -532,6 +553,7 @@ export const Editor = forwardRef(function Editor(
(function Editor( > - ( - - {formattingToolbarItems} - - )} - /> - + {!locked && ( + <> + ( + + {formattingToolbarItems} + + )} + /> + + + )}
diff --git a/src/features/sidebar/ui/NoteItem.tsx b/src/features/sidebar/ui/NoteItem.tsx index a1f0504..c584bbe 100644 --- a/src/features/sidebar/ui/NoteItem.tsx +++ b/src/features/sidebar/ui/NoteItem.tsx @@ -3,8 +3,10 @@ import { Check, Copy, Download, + FileLock, FileText, FolderInput, + LockOpen, Pin, PinOff, Trash2, @@ -32,6 +34,7 @@ import { cn } from '@/lib/utils' * @property selectedNoteId - The ID of the currently selected note, or `null`. * @property onSelectNote - Callback invoked when the user clicks this note. * @property onTogglePin - Callback to pin or unpin the note. + * @property onToggleLock - Callback to lock or unlock the note. * @property onDeleteNote - Callback invoked when the user confirms deletion. * @property onDuplicateNote - Callback invoked to duplicate the note. * @property onExportNote - Callback invoked to export the note as Markdown. @@ -44,6 +47,7 @@ interface NoteItemProps { selectedNoteId: string | null onSelectNote: (id: string) => void onTogglePin: (id: string, pinned: boolean) => void + onToggleLock: (id: string, locked: boolean) => void onDeleteNote: () => void onDuplicateNote: (noteId: string) => void onExportNote: (noteId: string) => void @@ -62,6 +66,7 @@ export function NoteItem({ selectedNoteId, onSelectNote, onTogglePin, + onToggleLock, onDeleteNote, onDuplicateNote, onExportNote, @@ -97,14 +102,18 @@ export function NoteItem({ onClick={() => onSelectNote(note.id)} className={cn(note.isPinned && 'hover:bg-primary/5')} > - {note.isPinned ? ( + {note.isPinned && ( - ) : ( + )} + {!note.isPinned && note.isLocked && ( + + )} + {!note.isPinned && !note.isLocked && ( )}
@@ -124,6 +133,16 @@ export function NoteItem({ )} {note.isPinned ? 'Unpin' : 'Pin to top'} + onToggleLock(note.id, !note.isLocked)} + > + {note.isLocked ? ( + + ) : ( + + )} + {note.isLocked ? 'Unlock' : 'Lock'} + diff --git a/src/features/sidebar/ui/NoteSidebar.tsx b/src/features/sidebar/ui/NoteSidebar.tsx index 37c5346..00185dd 100644 --- a/src/features/sidebar/ui/NoteSidebar.tsx +++ b/src/features/sidebar/ui/NoteSidebar.tsx @@ -55,6 +55,7 @@ interface NoteSidebarProps { onNewNote: () => void onDeleteNote: (noteId: string) => void onTogglePin: (noteId: string, pinned: boolean) => void + onToggleLock: (noteId: string, locked: boolean) => void onExportNote: (noteId: string) => void onDuplicateNote: (noteId: string) => void onImportNote: () => void @@ -75,6 +76,7 @@ export function NoteSidebar({ onNewNote, onDeleteNote, onTogglePin, + onToggleLock, onExportNote, onDuplicateNote, onImportNote, @@ -231,6 +233,7 @@ export function NoteSidebar({ selectedNoteId={selectedNoteId} onSelectNote={onSelectNote} onTogglePin={handleTogglePin} + onToggleLock={onToggleLock} onDeleteNote={() => setDeleteTarget(note.id)} onExportNote={onExportNote} onDuplicateNote={onDuplicateNote} @@ -243,6 +246,7 @@ export function NoteSidebar({ selectedNoteId, onSelectNote, handleTogglePin, + onToggleLock, onDuplicateNote, onExportNote, handleMoveToGroup, diff --git a/src/index.css b/src/index.css index b6f01cc..7d261b2 100644 --- a/src/index.css +++ b/src/index.css @@ -199,9 +199,56 @@ body { body { @apply bg-background text-foreground; } - [data-editor-root] { - @apply select-text; - } +} + +/* + * Editor text-selection overrides – placed OUTSIDE @layer base. + * + * BlockNote's stylesheets live outside any @layer, so rules declared + * inside Tailwind's @layer base lose the cascade battle. The same + * pattern already used for the dark-mode editor background override + * (see below) is applied here so that user-select: text wins over + * both the global select-none and any library defaults. + * + * When the editor is read-only (editable={false}), ProseMirror sets + * contentEditable="false" on the .ProseMirror element. On WebKit + * (Tauri's WKWebView) this implicitly forces user-select: none on + * the subtree. An un-layered rule with a specific selector is + * required to override this behaviour. + */ +[data-editor-root] { + -webkit-user-select: text; + user-select: text; +} +[data-editor-root] .ProseMirror { + -webkit-user-select: text; + user-select: text; +} + +/* + * Hide editing UI elements (drag handles, side menu, suggestion menu) + * when the editor is locked. The editor stays editable={true} so text + * selection works in WebKit, but all editing transactions are blocked by + * the readOnlyGuard ProseMirror plugin. + */ +[data-editor-root][data-locked] .bn-drag-handle, +[data-editor-root][data-locked] .bn-side-menu, +[data-editor-root][data-locked] .bn-add-block-button, +[data-editor-root][data-locked] .bn-suggestion-menu { + display: none !important; +} + +/* + * Locked-editor cursor override. + * + * While the editor remains editable={true} for text selection, the default + * text cursor (I-beam) is replaced with the standard pointer so it is clear + * the document cannot be edited. + */ +[data-editor-root][data-locked] .ProseMirror, +[data-editor-root][data-locked] .ProseMirror * { + cursor: default; + caret-color: transparent; } /* diff --git a/src/shared/ui/SaveStatusIndicator.tsx b/src/shared/ui/SaveStatusIndicator.tsx index d985455..efccbde 100644 --- a/src/shared/ui/SaveStatusIndicator.tsx +++ b/src/shared/ui/SaveStatusIndicator.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Check } from 'lucide-react' +import { AlertCircle, Check, Lock } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import type { SaveStatus } from '@/features/editor' import { cn } from '@/lib/utils' @@ -10,23 +10,30 @@ const SAVED_DISPLAY_MS = 3000 * Compact save-status indicator for the editor header. * * Shows a subtle dot-based indicator reflecting the auto-save state: + * - `locked` – lock icon (suppresses all other states) * - `saving` – pulsing dot * - `saved` – check icon only (fades out after 3 s) * - `error` – warning icon + "Save failed" * - `idle` – hidden */ -export function SaveStatusIndicator({ status }: { status: SaveStatus }) { +export function SaveStatusIndicator({ + status, + locked = false, +}: { + status: SaveStatus + /** When `true`, a lock icon is displayed and save status is suppressed. */ + locked?: boolean +}) { const [display, setDisplay] = useState(null) const savedTimerRef = useRef | undefined>( undefined ) - const isTimerActiveRef = useRef(false) useEffect(() => { clearTimeout(savedTimerRef.current) if (status === 'idle') { - if (!isTimerActiveRef.current) { + if (!savedTimerRef.current) { setDisplay(null) } return @@ -35,14 +42,21 @@ export function SaveStatusIndicator({ status }: { status: SaveStatus }) { setDisplay(status) if (status === 'saved') { - isTimerActiveRef.current = true savedTimerRef.current = setTimeout(() => { + savedTimerRef.current = undefined setDisplay(null) - isTimerActiveRef.current = false }, SAVED_DISPLAY_MS) } }, [status]) + if (locked) { + return ( + + + + ) + } + return (