diff --git a/.claude-done b/.claude-done deleted file mode 100644 index 0fd74521..00000000 --- a/.claude-done +++ /dev/null @@ -1,42 +0,0 @@ -# Drag & Drop Images — Implementation Summary - -## Problem -Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's -`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they -reach the webview's HTML5 DnD API — BlockNote never received the dropped files. - -## Solution -Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API -to listen for native file drops and handle them directly: - -1. **Rust backend** (`src-tauri/src/vault/image.rs`): - - Added `copy_image_to_vault` command that copies a file by OS path into - `vault/attachments/` with a timestamp-prefixed filename - - Refactored shared path logic into `prepare_attachment_path` helper - - Validates file exists and has an image extension before copying - -2. **Frontend** (`src/hooks/useImageDrop.ts`): - - Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop - - On `drop`: filters for image paths, copies each to vault, calls `onImageUrl` - - On `over`: shows drag overlay (paths aren't available until drop) - - `copyImageToVault` invokes the new Rust command and returns an asset URL - -3. **Editor integration** (`src/components/SingleEditorView.tsx`): - - `useInsertImageCallback` inserts a BlockNote image block at cursor position - - Wired into `useImageDrop` via the `onImageUrl` callback - - `vaultPath` threaded through Editor → EditorContent → SingleEditorView - -4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile` - on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command). - -## Commits -- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop -- `b047d0c` feat: handle Tauri native drag-drop for filesystem images -- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities -- `23ad982` fix: correct DragDropEvent type handling for 'over' events - -## Tests -- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions) -- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params -- All 300 Rust tests pass, 1167 frontend tests pass -- Coverage: Rust 85.47%, Frontend 78.64% diff --git a/.claude-pid b/.claude-pid new file mode 100644 index 00000000..db8555f6 --- /dev/null +++ b/.claude-pid @@ -0,0 +1 @@ +81859 diff --git a/design/note-templates.pen b/design/note-templates.pen new file mode 100644 index 00000000..ebcc2d64 --- /dev/null +++ b/design/note-templates.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} \ No newline at end of file diff --git a/src-tauri/src/frontmatter.rs b/src-tauri/src/frontmatter.rs index ae88284b..837f1b1c 100644 --- a/src-tauri/src/frontmatter.rs +++ b/src-tauri/src/frontmatter.rs @@ -15,7 +15,7 @@ pub enum FrontmatterValue { /// Characters that require a YAML string value to be quoted. fn has_yaml_special_chars(s: &str) -> bool { - s.contains(':') || s.contains('#') || s.contains('\n') + s.contains(':') || s.contains('#') } /// Check if a string starts with a YAML collection indicator (array or map). @@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String { format!(" - {}", quote_yaml_string(item)) } +/// Format a multi-line string as a YAML block scalar (`|`). +/// Each line is indented by 2 spaces; empty lines are preserved as blank. +fn format_block_scalar(s: &str) -> String { + let indented = s + .lines() + .map(|l| { + if l.is_empty() { + String::new() + } else { + format!(" {}", l) + } + }) + .collect::>() + .join("\n"); + format!("|\n{}", indented) +} + /// Format a number for YAML (integers without decimal, floats with). fn format_yaml_number(n: f64) -> String { if n.fract() == 0.0 { @@ -54,7 +71,9 @@ impl FrontmatterValue { pub fn to_yaml_value(&self) -> String { match self { FrontmatterValue::String(s) => { - if needs_yaml_quoting(s) { + if s.contains('\n') { + format_block_scalar(s) + } else if needs_yaml_quoting(s) { quote_yaml_string(s) } else { s.clone() @@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool { fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec { let yaml_key = format_yaml_key(key); let yaml_value = value.to_yaml_value(); - if yaml_value.contains('\n') { + if yaml_value.starts_with("|\n") { + // Block scalar: key and indicator on the same line, content follows + vec![format!("{}: {}", yaml_key, yaml_value)] + } else if yaml_value.contains('\n') { vec![format!("{}:", yaml_key), yaml_value] } else { vec![format!("{}: {}", yaml_key, yaml_value)] } } -/// Check if a line is a YAML list continuation (` - ...`) rather than a new key. -fn is_list_continuation(line: &str) -> bool { - line.starts_with(" - ") || line.starts_with(" -\t") +/// Check if a line continues the previous key's value (indented list item, +/// block scalar content, or blank line inside a block scalar). +fn is_value_continuation(line: &str) -> bool { + line.is_empty() || line.starts_with(" ") || line.starts_with('\t') } /// Split content into frontmatter body and the rest after the closing `---`. @@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue found_key = true; i += 1; - // Skip list continuation lines belonging to this key - while i < lines.len() && is_list_continuation(lines[i]) { + // Skip continuation lines belonging to this key (lists, block scalars) + while i < lines.len() && is_value_continuation(lines[i]) { i += 1; } // Insert replacement value (if any) @@ -699,6 +722,94 @@ mod tests { assert!(updated.contains("title: New Title")); } + // --- block scalar (multi-line string) tests --- + + #[test] + fn test_to_yaml_value_multiline_uses_block_scalar() { + let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string()); + let yaml = v.to_yaml_value(); + assert!(yaml.starts_with("|\n")); + assert!(yaml.contains(" line 1")); + assert!(yaml.contains(" line 2")); + } + + #[test] + fn test_format_yaml_field_block_scalar() { + let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string()); + let lines = format_yaml_field("template", &v); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("template: |\n")); + assert!(lines[0].contains(" ## Objective")); + assert!(lines[0].contains(" ## Timeline")); + } + + #[test] + fn test_update_frontmatter_block_scalar_add() { + let content = "---\ntype: Type\n---\n# Project\n"; + let template = "## Objective\n\n## Timeline"; + let updated = update_frontmatter_content( + content, + "template", + Some(FrontmatterValue::String(template.to_string())), + ) + .unwrap(); + assert!(updated.contains("template: |")); + assert!(updated.contains(" ## Objective")); + assert!(updated.contains(" ## Timeline")); + assert!(updated.contains("type: Type")); + } + + #[test] + fn test_update_frontmatter_block_scalar_replace() { + let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n"; + let new_template = "## New\n\n## Content"; + let updated = update_frontmatter_content( + content, + "template", + Some(FrontmatterValue::String(new_template.to_string())), + ) + .unwrap(); + assert!(updated.contains(" ## New")); + assert!(updated.contains(" ## Content")); + assert!(!updated.contains("## Old")); + assert!(!updated.contains("## Stuff")); + assert!(updated.contains("color: green")); + } + + #[test] + fn test_delete_frontmatter_block_scalar() { + let content = + "---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n"; + let updated = update_frontmatter_content(content, "template", None).unwrap(); + assert!(!updated.contains("template")); + assert!(!updated.contains("## Heading")); + assert!(updated.contains("color: green")); + } + + #[test] + fn test_roundtrip_block_scalar() { + let content = "---\ntype: Type\n---\n# Project\n"; + let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates."; + let updated = update_frontmatter_content( + content, + "template", + Some(FrontmatterValue::String(template.to_string())), + ) + .unwrap(); + // Parse back with gray_matter + let matter = gray_matter::Matter::::new(); + let parsed = matter.parse(&updated); + let data = parsed.data.unwrap(); + if let gray_matter::Pod::Hash(map) = data { + let roundtripped = map.get("template").unwrap().as_string().unwrap(); + assert!(roundtripped.contains("## Objective")); + assert!(roundtripped.contains("## Timeline")); + assert!(roundtripped.contains("Describe the goal.")); + } else { + panic!("Expected hash"); + } + } + #[test] fn test_update_frontmatter_no_body_after_closing() { // Frontmatter with title, no body after closing --- diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index d61bc5f2..661c560f 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -65,6 +65,9 @@ pub struct VaultEntry { /// Custom sidebar section label for Type entries, overriding auto-pluralization. #[serde(rename = "sidebarLabel")] pub sidebar_label: Option, + /// Markdown template for notes of this Type. When a new note is created + /// with this type, the template body is pre-filled after the frontmatter. + pub template: Option, /// Word count of the note body (excludes frontmatter and H1 title). #[serde(rename = "wordCount")] pub word_count: u32, @@ -109,6 +112,8 @@ struct Frontmatter { order: Option, #[serde(rename = "sidebar label", default)] sidebar_label: Option, + #[serde(default)] + template: Option, } /// Handles YAML fields that can be either a single string or a list of strings. @@ -153,6 +158,7 @@ const SKIP_KEYS: &[&str] = &[ "color", "order", "sidebar label", + "template", ]; /// Extract all wikilink-containing fields from raw YAML frontmatter. @@ -332,6 +338,7 @@ pub fn parse_md_file(path: &Path) -> Result { color: frontmatter.color, order: frontmatter.order, sidebar_label: frontmatter.sidebar_label, + template: frontmatter.template, word_count, outgoing_links, }) @@ -1088,6 +1095,45 @@ References: assert!(entry.relationships.get("sidebar label").is_none()); } + // --- template field tests --- + + #[test] + fn test_parse_template_from_type_entry() { + let dir = TempDir::new().unwrap(); + let content = + "---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert!(entry.template.is_some()); + } + + #[test] + fn test_parse_template_block_scalar() { + let dir = TempDir::new().unwrap(); + let content = + "---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert!(entry.template.is_some()); + let tmpl = entry.template.unwrap(); + assert!(tmpl.contains("## Objective")); + assert!(tmpl.contains("## Timeline")); + } + + #[test] + fn test_parse_template_missing_defaults_to_none() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\n---\n# Note\n"; + let entry = parse_test_entry(&dir, "type/note.md", content); + assert_eq!(entry.template, None); + } + + #[test] + fn test_template_not_in_relationships() { + let dir = TempDir::new().unwrap(); + let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n"; + let entry = parse_test_entry(&dir, "type/project.md", content); + assert!(entry.relationships.get("template").is_none()); + } + // Frontmatter update/delete tests are in frontmatter.rs // save_image tests are in vault/image.rs // purge_trash tests are in vault/trash.rs diff --git a/src/App.test.tsx b/src/App.test.tsx index c671ec97..7bf6d7b7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -34,6 +34,7 @@ const mockEntries = [ modifiedAt: 1700000000, createdAt: null, fileSize: 1024, + template: null, outgoingLinks: [], }, { @@ -50,6 +51,7 @@ const mockEntries = [ modifiedAt: 1700000000, createdAt: null, fileSize: 256, + template: null, outgoingLinks: [], }, ] diff --git a/src/App.tsx b/src/App.tsx index f79ac35d..e0fad570 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -332,7 +332,7 @@ function App() { {sidebarVisible && ( <>
- +
diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 35d8cca1..a874ef28 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 3ed863b8..93f39568 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -75,6 +75,7 @@ const mockEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: [], } diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 3f443288..0f380638 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -26,6 +26,7 @@ const mockEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: ['Test Project'], } @@ -374,6 +376,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -400,6 +403,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -426,6 +430,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -452,6 +457,7 @@ This is a test note with some words to count. icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -594,6 +600,7 @@ Status: Active icon: null, color: null, order: null, + template: null, // Body text also links to grow-newsletter outgoingLinks: ['responsibility/grow-newsletter'], } diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 5e4ae857..fb55201a 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 585dd46b..3bc4fd84 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, { @@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, { @@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, { @@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, { @@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, ] @@ -357,6 +362,7 @@ describe('getSortComparator', () => { icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) @@ -469,6 +475,7 @@ describe('NoteList sort controls', () => { icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) @@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: [], } @@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => { icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) @@ -1145,6 +1155,7 @@ const typeEntry: VaultEntry = { icon: null, color: null, order: null, + template: null, outgoingLinks: [], } diff --git a/src/components/QuickOpenPalette.test.tsx b/src/components/QuickOpenPalette.test.tsx index 39260005..bd914078 100644 --- a/src/components/QuickOpenPalette.test.tsx +++ b/src/components/QuickOpenPalette.test.tsx @@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial = {}): VaultEntry => ({ icon: null, color: null, order: null, + template: null, outgoingLinks: [], ...overrides, }) diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 6adb850b..45ff25d8 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -38,6 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'], }, { @@ -63,6 +64,7 @@ const MOCK_ENTRIES: VaultEntry[] = [ icon: null, color: null, order: null, + template: null, outgoingLinks: ['person/bob'], }, ] diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 9679cc47..de9e41b9 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -40,6 +40,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -66,6 +67,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -92,6 +94,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -118,6 +121,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -144,6 +148,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -170,6 +175,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -196,6 +202,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -222,6 +229,7 @@ const mockEntries: VaultEntry[] = [ color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, ] @@ -441,6 +449,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -467,6 +476,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], }, { @@ -492,6 +502,7 @@ describe('Sidebar', () => { icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, { @@ -517,6 +528,7 @@ describe('Sidebar', () => { icon: null, color: null, order: null, + template: null, outgoingLinks: [], }, ] @@ -602,6 +614,7 @@ describe('Sidebar', () => { color: null, order: null, sidebarLabel: null, + template: null, outgoingLinks: [], } render( {}} />) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4e7ce6ef..6ad30281 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,6 +32,7 @@ interface SidebarProps { onCreateType?: (type: string) => void onCreateNewType?: () => void onCustomizeType?: (typeName: string, icon: string, color: string) => void + onUpdateTypeTemplate?: (typeName: string, template: string) => void onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void modifiedCount?: number onCommitPush?: () => void @@ -229,10 +230,11 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: { ) } -function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: { +function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: { target: string | null; typeEntryMap: Record innerRef: React.Ref onCustomize: (prop: 'icon' | 'color', value: string) => void + onChangeTemplate: (template: string) => void onClose: () => void }) { if (!target) return null @@ -241,8 +243,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose onCustomize('icon', icon)} onChangeColor={(color) => onCustomize('color', color)} + onChangeTemplate={onChangeTemplate} onClose={onClose} /> @@ -253,7 +257,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, - onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse, + onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse, }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) @@ -302,6 +306,10 @@ export const Sidebar = memo(function Sidebar({ applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value) }, [customizeTarget, typeEntryMap, onCustomizeType]) + const handleChangeTemplate = useCallback((template: string) => { + if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template) + }, [customizeTarget, onUpdateTypeTemplate]) + const sectionProps = { entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onContextMenu: handleContextMenu, onToggle: toggleSection, @@ -346,7 +354,7 @@ export const Sidebar = memo(function Sidebar({ { closeContextMenu(); setCustomizeTarget(type) }} /> - + ) }) diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx index b4ec2186..3d638c09 100644 --- a/src/components/TabBar.test.tsx +++ b/src/components/TabBar.test.tsx @@ -10,7 +10,7 @@ function makeEntry(path: string, title: string): VaultEntry { status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null, fileSize: 0, - snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], + snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [], } } diff --git a/src/components/TypeCustomizePopover.test.tsx b/src/components/TypeCustomizePopover.test.tsx index 83fb2363..ca38c831 100644 --- a/src/components/TypeCustomizePopover.test.tsx +++ b/src/components/TypeCustomizePopover.test.tsx @@ -43,50 +43,42 @@ describe('ICON_OPTIONS', () => { describe('TypeCustomizePopover', () => { const onChangeIcon = vi.fn() const onChangeColor = vi.fn() + const onChangeTemplate = vi.fn() const onClose = vi.fn() + const renderPopover = (overrides: Partial[0]> = {}) => + render( + + ) + beforeEach(() => { vi.clearAllMocks() }) - it('renders color section and icon section', () => { - render( - - ) + it('renders color, icon, and template sections', () => { + renderPopover() expect(screen.getByText('COLOR')).toBeInTheDocument() expect(screen.getByText('ICON')).toBeInTheDocument() + expect(screen.getByText('TEMPLATE')).toBeInTheDocument() expect(screen.getByText('Done')).toBeInTheDocument() }) it('renders search input', () => { - render( - - ) + renderPopover() expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument() }) it('filters icons by search query', () => { - render( - - ) + renderPopover() const searchInput = screen.getByPlaceholderText('Search icons…') fireEvent.change(searchInput, { target: { value: 'book' } }) @@ -99,15 +91,7 @@ describe('TypeCustomizePopover', () => { }) it('shows empty state when no icons match search', () => { - render( - - ) + renderPopover() const searchInput = screen.getByPlaceholderText('Search icons…') fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } }) @@ -116,15 +100,7 @@ describe('TypeCustomizePopover', () => { }) it('calls onChangeColor when a color is clicked', () => { - render( - - ) + renderPopover() const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i) fireEvent.click(colorButtons[0]) @@ -133,47 +109,70 @@ describe('TypeCustomizePopover', () => { }) it('calls onChangeIcon when an icon is clicked', () => { - render( - - ) + renderPopover() fireEvent.click(screen.getByTitle('wrench')) expect(onChangeIcon).toHaveBeenCalledWith('wrench') }) it('calls onClose when Done is clicked', () => { - render( - - ) + renderPopover() fireEvent.click(screen.getByText('Done')) expect(onClose).toHaveBeenCalled() }) it('renders all color options including teal and pink', () => { - render( - - ) + renderPopover() expect(screen.getByTitle('Teal')).toBeInTheDocument() expect(screen.getByTitle('Pink')).toBeInTheDocument() }) + + // --- Template tests --- + + it('renders template textarea', () => { + renderPopover() + expect(screen.getByTestId('template-textarea')).toBeInTheDocument() + }) + + it('shows placeholder when template is empty', () => { + renderPopover() + expect(screen.getByPlaceholderText('Markdown template for new notes of this type…')).toBeInTheDocument() + }) + + it('displays current template value', () => { + renderPopover({ currentTemplate: '## Objective\n\n## Notes' }) + const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement + expect(textarea.value).toBe('## Objective\n\n## Notes') + }) + + it('updates template text on user input', () => { + renderPopover() + const textarea = screen.getByTestId('template-textarea') + fireEvent.change(textarea, { target: { value: '## New Template' } }) + expect((textarea as HTMLTextAreaElement).value).toBe('## New Template') + }) + + it('calls onChangeTemplate after debounce', async () => { + vi.useFakeTimers() + renderPopover() + const textarea = screen.getByTestId('template-textarea') + fireEvent.change(textarea, { target: { value: '## Debounced' } }) + + // Should not be called immediately + expect(onChangeTemplate).not.toHaveBeenCalled() + + // Fast-forward past debounce + vi.advanceTimersByTime(600) + expect(onChangeTemplate).toHaveBeenCalledWith('## Debounced') + + vi.useRealTimers() + }) + + it('treats null template as empty string', () => { + renderPopover({ currentTemplate: null }) + const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement + expect(textarea.value).toBe('') + }) }) diff --git a/src/components/TypeCustomizePopover.tsx b/src/components/TypeCustomizePopover.tsx index 37781806..215826ba 100644 --- a/src/components/TypeCustomizePopover.tsx +++ b/src/components/TypeCustomizePopover.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { MagnifyingGlass } from '@phosphor-icons/react' import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry' import { ACCENT_COLORS } from '../utils/typeColors' @@ -13,21 +13,40 @@ function filterIcons(icons: IconEntry[], query: string): IconEntry[] { interface TypeCustomizePopoverProps { currentIcon: string | null currentColor: string | null + currentTemplate: string | null onChangeIcon: (icon: string) => void onChangeColor: (color: string) => void + onChangeTemplate: (template: string) => void onClose: () => void } +/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */ +function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void { + const timerRef = useRef | undefined>(undefined) + const fnRef = useRef(fn) + useEffect(() => { fnRef.current = fn }) + + useEffect(() => () => { clearTimeout(timerRef.current) }, []) + + return useCallback((v: string) => { + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => fnRef.current(v), delay) + }, [delay]) +} + export function TypeCustomizePopover({ currentIcon, currentColor, + currentTemplate, onChangeIcon, onChangeColor, + onChangeTemplate, onClose, }: TypeCustomizePopoverProps) { const [selectedColor, setSelectedColor] = useState(currentColor) const [selectedIcon, setSelectedIcon] = useState(currentIcon) const [search, setSearch] = useState('') + const [templateText, setTemplateText] = useState(currentTemplate ?? '') const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search]) @@ -41,10 +60,17 @@ export function TypeCustomizePopover({ onChangeIcon(name) } + const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500) + + const handleTemplateChange = (value: string) => { + setTemplateText(value) + debouncedSaveTemplate(value) + } + return (
e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()} > @@ -84,7 +110,7 @@ export function TypeCustomizePopover({
{/* Icon grid */} -
+
{filteredIcons.length === 0 ? (
No icons found @@ -109,6 +135,17 @@ export function TypeCustomizePopover({ )}
+ {/* Template section */} +
TEMPLATE
+