Crosmos is a Tauri-based knowledge-base application with a custom block-based editor. It features a workspace system ("spaces"), file management, and an immutable editor state architecture.
- Frontend: React 19 + TypeScript + Vite
- Backend: Tauri v2 (Rust)
- State Management: Zustand
- Styling: Tailwind CSS v4 + custom CSS
- Editor: Custom block-based contentEditable implementation
# Start development server (frontend + Tauri)
bun run dev
# Build the application
bun run build
# Preview production build
bun run preview
# Run Tauri CLI commands
bun run tauri <command>The editor follows a strict immutability pattern (inspired from the implementation of Lexical from facebook) with three core layers:
- EditorState (
src/lib/editor/core/editorState.ts): Top-level immutable state container
- Contains
NoteModel, selection state, and undo/redo stacks - Every operation returns a new
EditorStateinstance - Methods:
withContent(),withSelection(),forceSelect(),push(),undo(),redo()
- NoteModel (
src/lib/editor/core/noteModel.ts): Immutable document model
- Contains array of blocks with O(1) lookup via internal Map
- Every operation returns a new
NoteModelinstance - Key methods:
updateBlock(),insertTextAtOffset(),mergeBlocks(),handleEnter() - Converts to/from JSON for persistence
- Block System: Plugin-based block types registered in
src/lib/editor/registry.ts
- Each block type has a config with:
className,render,handleEnter,shortcuts - Block types: paragraph, heading1/2/3, codeBlock, bulletList, lineBreak
- New block types can be added by implementing
BlockTypeConfigand registering
- Selection capture:
getCurrentSelection()insrc/lib/editor/core/selection.ts - Calculates offset within block content from DOM selection
- Uses
data-block-idattributes to identify blocks - Selection restoration:
restoreSelection() - Uses TreeWalker to find correct text node and offset
- Handles empty blocks and edge cases
- Called after state updates via
useSelectionhook - Force selection: Used when operations require immediate caret repositioning (e.g., Enter key splits block and moves caret to new block)
- Enter: Splits block at cursor, creates new block below, moves cursor to start of new block
- Shift+Enter: Inserts newline within block (browser default)
- Backspace at offset 0: Merges current block with previous block, positions cursor at merge point
- editorStore (
src/stores/editorStore.ts): Manages editor state, tabs, file I/O - Tab system: Each tab tracks its own
EditorState - File operations:
openFile(),saveFile(),closeTab(),setActiveTab() - Auto-saves on tab switches and dirty state changes
- spaceStore (
src/stores/spaceStore.ts): Manages workspaces - "Spaces" are directories containing notes
- Communicates with Tauri backend via
invoke()
- Debounced auto-save (1 second delay)
- Manual save shortcut: Cmd/Ctrl+S
- Clears pending saves when file becomes clean or switches
- File I/O commands:
read_file_cmd,write_file_cmd - Space management:
get_spaces,add_spaces,set_last_opened,remove_space - Uses Tauri plugins: fs, dialog, opener, store
- Rust backend in
src-tauri/with commands insrc-tauri/src/commands/
When updating content:
const updatedModel = noteModel.updateBlock(id, content); // Returns new NoteModel
const newState = editorState.withContent(updatedModel); // Returns new EditorState
setEditorState(newState); // Triggers re-renderOperations that modify structure (Enter, Backspace merge) use forceSelect():
const newState = editorState
.withContent(newNote)
.forceSelect({ blockId, offset, isCollapsed: true });The useSelection hook watches for forceSelection flag and restores cursor position.
Blocks are rendered via BlockView component which:
- Looks up block config from registry
- Wraps block with
data-block-idanddata-block-typeattributes - Renders block-specific component from config
- Each block handles its own contentEditable div with
onInputandonKeyDowncallbacks
src/
├── lib/editor/
│ ├── core/ # Core editor logic (EditorState, NoteModel, selection)
│ ├── blocks/ # Block type implementations
│ ├── types.ts # Type definitions
│ └── registry.ts # Block type registry
├── components/
│ ├── editor/ # Editor UI (Editor, BlockView, Tabs)
│ ├── block/ # Block component implementations
│ └── [other UI]
├── stores/ # Zustand stores
├── hooks/ # React hooks
└── styles/ # CSS files
src-tauri/
├── src/
│ ├── main.rs # Entry point
│ ├── commands/ # Tauri commands
│ └── models/ # Rust data models
└── Cargo.toml # Rust dependencies
Notes are stored as JSON with structure:
{
id: string,
title: string,
version: number,
blocks: Block[],
createdAt: number,
updatedAt: number
}Each block:
{
id: string,
type: BlockType,
content: string,
metadata?: Record<string, any>,
children?: Block[]
}- Create block component in
src/components/block/implementingBlockProps - Export
BlockTypeConfigfrom component file - Register in
src/lib/editor/registry.ts - Add type to
BlockTypeunion insrc/lib/editor/types.ts
- Check
data-block-idattributes are present on block wrappers - Verify contentEditable element exists within block
- Use browser dev tools to inspect Selection and Range objects
- Check console for selection restoration errors
- Never mutate
blocksarray orBlockobjects directly - Always use
NoteModelmethods that return new instances - Chain operations on
EditorState:state.withContent(...).forceSelect(...) - Use
push()to add to undo stack for undoable operations - remember we gotta implement richText with consistent behaviour