/// import { Cell, computed, type Default, handler, lift, NAME, navigateTo, pattern, Stream, UI, wish, } from "commontools"; import Note from "./note.tsx"; // Type for backlinks (inline to work around CLI path resolution bug) type MentionableCharm = { [NAME]?: string; isHidden?: boolean; mentioned: MentionableCharm[]; backlinks: MentionableCharm[]; }; // Simple random ID generator (crypto.randomUUID not available in pattern env) const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; type NoteCharm = { [NAME]?: string; title?: string; content?: string; isHidden?: boolean; noteId?: string; }; type MinimalCharm = { [NAME]?: string; }; interface Input { title?: Default; notes?: Default; isNotebook?: Default; // Marker for identification through proxy isHidden?: Default; // Hide from default-app charm list when nested } interface Output { title: string; notes: NoteCharm[]; noteCount: number; isNotebook: boolean; isHidden: boolean; backlinks: MentionableCharm[]; // LLM-callable streams for omnibot integration createNote: Stream<{ title: string; content: string }>; createNotes: Stream<{ notesData: Array<{ title: string; content: string }> }>; setTitle: Stream<{ newTitle: string }>; createNotebook: Stream<{ title: string; notesData?: Array<{ title: string; content: string }>; }>; } // Handler to show the new note modal const showNewNoteModal = handler }>( (_, { showNewNotePrompt }) => showNewNotePrompt.set(true), ); // Handler to show the new notebook modal (from header button) const showNewNotebookModal = handler< void, { showNewNestedNotebookPrompt: Cell } >((_, { showNewNestedNotebookPrompt }) => showNewNestedNotebookPrompt.set(true) ); // Handler to create note and navigate to it (unless "Create Another" was used) const createNoteAndOpen = handler< void, { newNoteTitle: Cell; showNewNotePrompt: Cell; notes: Cell; allCharms: Cell; usedCreateAnotherNote: Cell; } >(( _, { newNoteTitle, showNewNotePrompt, notes, allCharms, usedCreateAnotherNote }, ) => { const title = newNoteTitle.get() || "New Note"; const newNote = Note({ title, content: "", isHidden: true, noteId: generateId(), }); allCharms.push(newNote as unknown as NoteCharm); notes.push(newNote as unknown as NoteCharm); const shouldNavigate = !usedCreateAnotherNote.get(); // Reset modal state showNewNotePrompt.set(false); newNoteTitle.set(""); usedCreateAnotherNote.set(false); // Only navigate if "Create Another" was never used in this session if (shouldNavigate) { return navigateTo(newNote); } }); // Handler to create note and stay in modal to create another const createNoteAndContinue = handler< void, { newNoteTitle: Cell; notes: Cell; allCharms: Cell; usedCreateAnotherNote: Cell; } >((_, { newNoteTitle, notes, allCharms, usedCreateAnotherNote }) => { const title = newNoteTitle.get() || "New Note"; const newNote = Note({ title, content: "", isHidden: true, noteId: generateId(), }); allCharms.push(newNote as unknown as NoteCharm); notes.push(newNote as unknown as NoteCharm); // Mark that "Create Another" was used usedCreateAnotherNote.set(true); // Keep modal open, just clear the title for the next note newNoteTitle.set(""); }); // Handler to cancel new note prompt const cancelNewNotePrompt = handler< void, { showNewNotePrompt: Cell; newNoteTitle: Cell; usedCreateAnotherNote: Cell; } >((_, { showNewNotePrompt, newNoteTitle, usedCreateAnotherNote }) => { showNewNotePrompt.set(false); newNoteTitle.set(""); usedCreateAnotherNote.set(false); }); // Handler to remove a note from this notebook (but keep it in the space) const removeFromNotebook = handler< Record, { note: Cell; notes: Cell } >((_, { note, notes }) => { const notebookNotes = notes.get(); const index = notebookNotes.findIndex((n: any) => Cell.equals(n, note)); if (index !== -1) { const copy = [...notebookNotes]; copy.splice(index, 1); notes.set(copy); } // Make it visible in the space again note.key("isHidden").set(false); }); // Handler for dropping a charm onto this notebook const _handleCharmDrop = handler< { detail: { sourceCell: Cell } }, { notes: Cell } >((event, { notes }) => { const sourceCell = event.detail.sourceCell; const notesList = notes.get() ?? []; // Prevent duplicates using Cell.equals const alreadyExists = notesList.some((n) => Cell.equals(sourceCell, n as any) ); if (alreadyExists) return; // Hide from Pages list sourceCell.key("isHidden").set(true); // Add to notebook - push cell reference, not value, to maintain charm identity notes.push(sourceCell as unknown as NoteCharm); }); // Handler for dropping items onto the current notebook's card // This MOVES the dropped notebook - removes from all other notebooks, adds here // Supports multi-item drag: if dragged item is in selection, moves ALL selected items const handleDropOntoCurrentNotebook = handler< { detail: { sourceCell: Cell } }, { notes: Cell; notebooks: Cell; selectedNoteIndices: Cell; } >((event, { notes, notebooks, selectedNoteIndices }) => { const sourceCell = event.detail.sourceCell; const notesList = notes.get(); const selected = selectedNoteIndices.get(); // Check if dragged item is in the selection (from a sibling notebook drag) // For sibling notebooks, we check if the dragged cell matches any selected item const draggedIndex = notesList.findIndex((n: any) => Cell.equals(sourceCell, n) ); const isDraggedInSelection = draggedIndex >= 0 && selected.includes(draggedIndex); if (isDraggedInSelection && selected.length > 1) { // Multi-item move: gather all selected items const itemsToMove = selected.map((idx) => notesList[idx]).filter(Boolean); // Track by noteId and title (like moveSelectedToNotebook) const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; for (const item of itemsToMove) { const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } } const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) { return true; } return false; }; // Remove from ALL other notebooks' notes arrays (move semantics) const notebooksList = notebooks.get(); for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = (nbNotesCell.get() as unknown[]) ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered as NoteCharm[]); } } // Add all to this notebook (deduplicated) for (const item of itemsToMove) { const alreadyExists = notesList.some((n) => Cell.equals(item as any, n as any) ); if (!alreadyExists) { notes.push(item as any); (item as any).key?.("isHidden")?.set?.(true); } } // Clear selection selectedNoteIndices.set([]); } else { // Single-item move (existing logic) const sourceTitle = (sourceCell as any).key("title").get(); // Prevent duplicates const alreadyExists = notesList.some((n) => Cell.equals(sourceCell, n as any) ); if (alreadyExists) return; // Remove from ALL other notebooks' notes arrays (move semantics) const notebooksList = notebooks.get(); for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = (nbNotesCell.get() as unknown[]) ?? []; // Find and remove by title or Cell.equals const filtered = nbNotes.filter((n: any) => { if (n?.title === sourceTitle) return false; if (Cell.equals(sourceCell, n as any)) return false; return true; }); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered as NoteCharm[]); } } // Hide from default-app charm list sourceCell.key("isHidden").set(true); // Add to this notebook notes.push(sourceCell as any); } }); // Handler for dropping any item onto a notebook - moves from current notebook to target // Supports multi-item drag: if dragged item is in selection, moves ALL selected items const handleDropOntoNotebook = handler< { detail: { sourceCell: Cell } }, { targetNotebook: Cell<{ notes?: unknown[]; isNotebook?: boolean }>; currentNotes: Cell; selectedNoteIndices: Cell; notebooks: Cell; } >((event, { targetNotebook, currentNotes, selectedNoteIndices, notebooks }) => { const sourceCell = event.detail.sourceCell; // Check if target is actually a notebook const isTargetNotebook = targetNotebook.key("isNotebook").get(); if (!isTargetNotebook) return; const targetNotesCell = targetNotebook.key("notes"); const targetNotesList = (targetNotesCell.get() as unknown[]) ?? []; const currentList = currentNotes.get(); const selected = selectedNoteIndices.get(); // Check if dragged item is in the selection const draggedIndex = currentList.findIndex((n: any) => Cell.equals(sourceCell, n) ); const isDraggedInSelection = draggedIndex >= 0 && selected.includes(draggedIndex); if (isDraggedInSelection && selected.length > 1) { // Multi-item move: gather all selected items const itemsToMove = selected.map((idx) => currentList[idx]).filter(Boolean); // Track by noteId and title (like moveSelectedToNotebook) const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; for (const item of itemsToMove) { const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } } const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) { return true; } return false; }; // Add all to target (deduplicated) for (const item of itemsToMove) { const alreadyInTarget = targetNotesList.some((n) => Cell.equals(item as any, n as any) ); if (!alreadyInTarget) { targetNotesCell.push(item); (item as any).key?.("isHidden")?.set?.(true); } } // Remove from all notebooks EXCEPT the target (move semantics) const notebooksList = notebooks.get(); const targetTitle = targetNotebook.key("title").get(); for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); // Skip the target notebook - we just added items there const nbTitle = nbCell.key("title").get(); if (nbTitle === targetTitle) continue; const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Remove from current notebook (which is different from target) const filtered = currentList.filter((n: any) => !shouldRemove(n)); currentNotes.set(filtered); // Clear selection selectedNoteIndices.set([]); } else { // Single-item move (existing logic) // Prevent duplicates in target const alreadyInTarget = targetNotesList.some((n) => Cell.equals(sourceCell, n as any) ); if (alreadyInTarget) return; // Remove from current notebook if present const indexInCurrent = currentList.findIndex((n: any) => Cell.equals(sourceCell, n) ); if (indexInCurrent !== -1) { const copy = [...currentList]; copy.splice(indexInCurrent, 1); currentNotes.set(copy); } // Hide from default-app charm list sourceCell.key("isHidden").set(true); // Add to target notebook targetNotesCell.push(sourceCell); } }); // Create nested notebook and navigate to it (unless "Create Another" was used) const createNestedNotebookAndOpen = handler< void, { newNestedNotebookTitle: Cell; showNewNestedNotebookPrompt: Cell; notes: Cell; allCharms: Cell; usedCreateAnotherNotebook: Cell; } >(( _, { newNestedNotebookTitle, showNewNestedNotebookPrompt, notes, allCharms, usedCreateAnotherNotebook, }, ) => { const title = newNestedNotebookTitle.get() || "New Notebook"; // Pass isHidden: true at creation time (like Note does) to hide from default-app const nb = Notebook({ title, notes: [], isHidden: true }); allCharms.push(nb as unknown as NoteCharm); notes.push(nb as unknown as NoteCharm); const shouldNavigate = !usedCreateAnotherNotebook.get(); // Reset modal state showNewNestedNotebookPrompt.set(false); newNestedNotebookTitle.set(""); usedCreateAnotherNotebook.set(false); // Only navigate if "Create Another" was never used in this session if (shouldNavigate) { return navigateTo(nb); } }); // Create nested notebook and keep modal open for another const createNestedNotebookAndContinue = handler< void, { newNestedNotebookTitle: Cell; notes: Cell; allCharms: Cell; usedCreateAnotherNotebook: Cell; } >(( _, { newNestedNotebookTitle, notes, allCharms, usedCreateAnotherNotebook }, ) => { const title = newNestedNotebookTitle.get() || "New Notebook"; // Pass isHidden: true at creation time (like Note does) to hide from default-app const nb = Notebook({ title, notes: [], isHidden: true }); allCharms.push(nb as unknown as NoteCharm); notes.push(nb as unknown as NoteCharm); // Mark that "Create Another" was used usedCreateAnotherNotebook.set(true); newNestedNotebookTitle.set(""); }); // Cancel nested notebook creation const cancelNewNestedNotebookPrompt = handler< void, { showNewNestedNotebookPrompt: Cell; newNestedNotebookTitle: Cell; usedCreateAnotherNotebook: Cell; } >(( _, { showNewNestedNotebookPrompt, newNestedNotebookTitle, usedCreateAnotherNotebook, }, ) => { showNewNestedNotebookPrompt.set(false); newNestedNotebookTitle.set(""); usedCreateAnotherNotebook.set(false); }); // Simple button handler: Go to All Notes (no menu state) const goToAllNotes = handler }>( (_, { allCharms }) => { const charms = allCharms.get(); const existing = charms.find((charm: any) => { const name = charm?.[NAME]; return typeof name === "string" && name.startsWith("All Notes"); }); if (existing) { return navigateTo(existing); } }, ); // Handler for clicking on a backlink const handleBacklinkClick = handler }>( (_, { charm }) => navigateTo(charm), ); // Handler to navigate to parent notebook const goToParent = handler }>( (_, { parent }) => navigateTo(parent), ); // Handler to select all notes in this notebook const selectAllNotes = handler< Record, { notes: Cell; selectedNoteIndices: Cell } >((_, { notes, selectedNoteIndices }) => { const notesList = notes.get(); selectedNoteIndices.set(notesList.map((_, i) => i)); }); // Handler to deselect all notes const deselectAllNotes = handler< Record, { selectedNoteIndices: Cell } >((_, { selectedNoteIndices }) => { selectedNoteIndices.set([]); }); // Handler to duplicate selected notes const duplicateSelectedNotes = handler< Record, { notes: Cell; selectedNoteIndices: Cell; allCharms: Cell; } >((_, { notes, selectedNoteIndices, allCharms }) => { const selected = selectedNoteIndices.get(); const notesList = notes.get(); // Collect copies first, then batch push (reduces N reactive cycles to 1) const copies: NoteCharm[] = []; for (const idx of selected) { const original = notesList[idx]; if (original) { copies.push(Note({ title: (original.title ?? "Note") + " (Copy)", content: original.content ?? "", isHidden: true, noteId: generateId(), }) as unknown as NoteCharm); } } allCharms.push(...copies); notes.push(...copies); selectedNoteIndices.set([]); }); type NotebookCharm = { [NAME]?: string; notes?: NoteCharm[]; }; // Handler to permanently delete selected notes from the space const deleteSelectedNotes = handler< Record, { notes: Cell; selectedNoteIndices: Cell; allCharms: Cell; notebooks: Cell; } >((_, { notes, selectedNoteIndices, allCharms, notebooks }) => { const selected = selectedNoteIndices.get(); const notesList = notes.get(); const allCharmsList = allCharms.get(); const notebooksList = notebooks.get(); // Collect noteIds and titles to delete (titles for notebooks which don't have noteId) const noteIdsToDelete: string[] = []; const titlesToDelete: string[] = []; for (const idx of selected) { const item = notesList[idx]; const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { noteIdsToDelete.push(noteId); } else if (title) { // Notebooks don't have noteId, use title instead titlesToDelete.push(title); } } // Helper to check if item should be deleted const shouldDelete = (n: any) => { if (n?.noteId && noteIdsToDelete.includes(n.noteId)) return true; if (!n?.noteId && n?.title && titlesToDelete.includes(n.title)) return true; return false; }; // Remove from all notebooks first (including this one) for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldDelete(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Also remove from this notebook's notes array const filteredNotes = notesList.filter((n: any) => !shouldDelete(n)); notes.set(filteredNotes); // Remove from allCharms (permanent delete) const filteredCharms = allCharmsList.filter((charm: any) => !shouldDelete(charm) ); allCharms.set(filteredCharms); selectedNoteIndices.set([]); }); // Handler to add selected notes to another notebook const addSelectedToNotebook = handler< { target?: { value: string }; detail?: { value: string } }, { notes: Cell; selectedNoteIndices: Cell; notebooks: Cell; selectedAddNotebook: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; } >(( event, { notes, selectedNoteIndices, notebooks, selectedAddNotebook, showNewNotebookPrompt, pendingNotebookAction, }, ) => { // Handle both native select (target.value) and ct-select (detail.value) const value = event.target?.value ?? event.detail?.value ?? ""; if (!value) return; // Handle "new-*" - show prompt to get name from user if (value === "new") { pendingNotebookAction.set("add"); showNewNotebookPrompt.set(true); selectedAddNotebook.set(""); return; } // Add to existing notebook const nbIndex = parseInt(value, 10); if (nbIndex < 0) return; const selected = selectedNoteIndices.get(); const notesList = notes.get(); const targetNotebookCell = notebooks.key(nbIndex); const targetNotebookNotes = targetNotebookCell.key("notes"); // Collect notes first, then batch push (reduces N reactive cycles to 1) const notesToAdd: NoteCharm[] = []; for (const idx of selected) { const note = notesList[idx]; if (note) notesToAdd.push(note); } (targetNotebookNotes as Cell).push(...notesToAdd); selectedNoteIndices.set([]); selectedAddNotebook.set(""); }); // Handler to move selected notes to another notebook (remove from current) const moveSelectedToNotebook = handler< { target?: { value: string }; detail?: { value: string } }, { notes: Cell; selectedNoteIndices: Cell; notebooks: Cell; selectedMoveNotebook: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; } >(( event, { notes, selectedNoteIndices, notebooks, selectedMoveNotebook, showNewNotebookPrompt, pendingNotebookAction, }, ) => { // Handle both native select (target.value) and ct-select (detail.value) const value = event.target?.value ?? event.detail?.value ?? ""; if (!value) return; // Handle "new-*" - show prompt to get name from user if (value === "new") { pendingNotebookAction.set("move"); showNewNotebookPrompt.set(true); selectedMoveNotebook.set(""); return; } // Move to existing notebook const nbIndex = parseInt(value, 10); if (nbIndex < 0) return; const selected = selectedNoteIndices.get(); const notesList = notes.get(); const notebooksList = notebooks.get(); const targetNotebookCell = notebooks.key(nbIndex); const targetNotebookNotes = targetNotebookCell.key("notes"); // Collect notes/notebooks and IDs/titles for removal const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; // For notebooks (no noteId) const notesToMove: NoteCharm[] = []; for (const idx of selected) { const item = notesList[idx]; const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } if (item) notesToMove.push(item); } // Helper to check if item should be removed const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) return true; return false; }; // Add to target notebook in one operation (targetNotebookNotes as Cell).push(...notesToMove); // Remove from all notebooks for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { // Don't remove from the target notebook we just added to if (nbIdx === nbIndex) continue; const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Remove from this notebook const filtered = notesList.filter((n: any) => !shouldRemove(n)); notes.set(filtered); selectedNoteIndices.set([]); selectedMoveNotebook.set(""); }); // Handler to create notebook from prompt and add/move selected notes const createNotebookFromPrompt = handler< void, { newNotebookName: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; selectedNoteIndices: Cell; notes: Cell; allCharms: Cell; notebooks: Cell; } >((_, state) => { const { newNotebookName, showNewNotebookPrompt, pendingNotebookAction, selectedNoteIndices, notes, allCharms, notebooks, } = state; const name = newNotebookName.get().trim() || "New Notebook"; const action = pendingNotebookAction.get(); // Gather selected items and track by noteId (notes) or title (notebooks) const selected = selectedNoteIndices.get(); const notesList = notes.get(); const selectedItems: NoteCharm[] = []; const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; // For notebooks (no noteId) for (const idx of selected) { const item = notesList[idx]; if (item) { selectedItems.push(item); const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } } } // Helper to check if item should be removed const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) return true; return false; }; // Create the notebook with items already included (hidden from default-app) const newNotebook = Notebook({ title: name, notes: selectedItems, isHidden: true, }); allCharms.push(newNotebook as unknown as MinimalCharm); if (action === "move") { // For move: remove selected items from existing notebooks and this notebook const notebooksList = notebooks.get(); // Remove from all existing notebooks for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Remove selected items from this notebook, then add new notebook const filtered = notesList.filter((n: any) => !shouldRemove(n)); notes.set([...filtered, newNotebook as unknown as NoteCharm]); } else { // For add: just add the new notebook as sibling notes.push(newNotebook as unknown as NoteCharm); } // For add: notes are already in the new notebook, no removal needed // Clean up state selectedNoteIndices.set([]); newNotebookName.set(""); pendingNotebookAction.set(""); showNewNotebookPrompt.set(false); }); // Handler to cancel new notebook prompt const cancelNewNotebookPrompt = handler< void, { showNewNotebookPrompt: Cell; newNotebookName: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; selectedAddNotebook: Cell; selectedMoveNotebook: Cell; } >((_, state) => { state.showNewNotebookPrompt.set(false); state.newNotebookName.set(""); state.pendingNotebookAction.set(""); state.selectedAddNotebook.set(""); state.selectedMoveNotebook.set(""); }); // Handler to toggle visibility of all selected notes const _toggleSelectedVisibility = handler< { detail: { checked: boolean } }, { notes: Cell; selectedNoteIndices: Cell } >((event, { notes, selectedNoteIndices }) => { const selected = selectedNoteIndices.get(); const makeVisible = event.detail?.checked ?? false; for (const idx of selected) { const noteCell = notes.key(idx); if (noteCell) { noteCell.key("isHidden").set(!makeVisible); } } selectedNoteIndices.set([]); }); // Handler to start editing title const startEditingTitle = handler< Record, { isEditingTitle: Cell } >((_, { isEditingTitle }) => { isEditingTitle.set(true); }); // Handler to stop editing title const stopEditingTitle = handler< Record, { isEditingTitle: Cell } >((_, { isEditingTitle }) => { isEditingTitle.set(false); }); // Handler for keydown on title input (Enter to save) const handleTitleKeydown = handler< { key?: string }, { isEditingTitle: Cell } >((event, { isEditingTitle }) => { if (event?.key === "Enter") { isEditingTitle.set(false); } }); // Handler to toggle preview expansion for a note const _togglePreviewExpansion = handler< Record, { index: number; expandedPreviews: Cell } >((_, { index, expandedPreviews }) => { const current = expandedPreviews.get(); if (current.includes(index)) { expandedPreviews.set(current.filter((i) => i !== index)); } else { expandedPreviews.set([...current, index]); } }); // Handler to toggle checkbox selection with shift-click support const toggleNoteCheckbox = handler< { shiftKey?: boolean }, { index: number; selectedNoteIndices: Cell; lastSelectedNoteIndex: Cell; } >((event, { index, selectedNoteIndices, lastSelectedNoteIndex }) => { const current = selectedNoteIndices.get(); const lastIdx = lastSelectedNoteIndex.get(); if (event?.shiftKey && lastIdx >= 0 && lastIdx !== index) { const start = Math.min(lastIdx, index); const end = Math.max(lastIdx, index); const range: number[] = []; for (let i = start; i <= end; i++) { range.push(i); } selectedNoteIndices.set([...new Set([...current, ...range])]); } else { const idx = current.indexOf(index); if (idx >= 0) { selectedNoteIndices.set(current.filter((i: number) => i !== index)); } else { selectedNoteIndices.set([...current, index]); } } lastSelectedNoteIndex.set(index); }); // LLM-callable handler: Create a single note in this notebook const handleCreateNote = handler< { title: string; content: string }, { notes: Cell; allCharms: Cell } >(({ title: noteTitle, content }, { notes, allCharms }) => { const newNote = Note({ title: noteTitle, content, isHidden: true, noteId: generateId(), }); allCharms.push(newNote as unknown as NoteCharm); notes.push(newNote as unknown as NoteCharm); return newNote; }); // LLM-callable handler: Create multiple notes in bulk const handleCreateNotes = handler< { notesData: Array<{ title: string; content: string }> }, { notes: Cell; allCharms: Cell } >(({ notesData }, { notes, allCharms }) => { // Collect notes first, then batch push (reduces N reactive cycles to 1) const created: NoteCharm[] = []; for (const data of notesData) { created.push(Note({ title: data.title, content: data.content, isHidden: true, noteId: generateId(), }) as unknown as NoteCharm); } allCharms.push(...created); notes.push(...created); return created; }); // LLM-callable handler: Rename the notebook const handleSetTitle = handler< { newTitle: string }, { title: Cell } >(({ newTitle }, { title }) => { title.set(newTitle); return newTitle; }); // LLM-callable handler: Create a new notebook (optionally with notes) const handleCreateNotebook = handler< { title: string; notesData?: Array<{ title: string; content: string }> }, { allCharms: Cell } >(({ title: nbTitle, notesData }, { allCharms }) => { // Collect notes first, then batch push (reduces N reactive cycles to 1) const notesToAdd: NoteCharm[] = []; if (notesData && notesData.length > 0) { for (const data of notesData) { notesToAdd.push(Note({ title: data.title, content: data.content, isHidden: true, noteId: generateId(), }) as unknown as NoteCharm); } } // Create the notebook with the notes const newNotebook = Notebook({ title: nbTitle, notes: notesToAdd, }); // Batch push all items at once allCharms.push(...notesToAdd, newNotebook as unknown as NoteCharm); return newNotebook; }); const Notebook = pattern( ({ title, notes, isNotebook, isHidden }) => { const { allCharms } = wish<{ allCharms: NoteCharm[] }>("/"); // Use lift() for proper reactive tracking of notes.length const noteCount = lift((args: { n: NoteCharm[] }) => args.n.length)({ n: notes, }); const hasNotes = lift((args: { n: NoteCharm[] }) => args.n.length > 0)({ n: notes, }); // Selection state for multi-select const selectedNoteIndices = Cell.of([]); const lastSelectedNoteIndex = Cell.of(-1); const selectedAddNotebook = Cell.of(""); const selectedMoveNotebook = Cell.of(""); // Computed helpers for selection const selectedCount = computed(() => selectedNoteIndices.get().length); const hasSelection = computed(() => selectedNoteIndices.get().length > 0); // State for "New Notebook" prompt modal const showNewNotebookPrompt = Cell.of(false); const newNotebookName = Cell.of(""); const pendingNotebookAction = Cell.of<"add" | "move" | "">(""); // Track which action triggered the modal // State for "New Note" prompt modal const showNewNotePrompt = Cell.of(false); const newNoteTitle = Cell.of(""); const usedCreateAnotherNote = Cell.of(false); // Track if "Create Another" was used // State for "New Nested Notebook" prompt modal (from dropdown menu) const showNewNestedNotebookPrompt = Cell.of(false); const newNestedNotebookTitle = Cell.of(""); const usedCreateAnotherNotebook = Cell.of(false); // Track if "Create Another" was used // Backlinks - populated by backlinks-index.tsx const backlinks = Cell.of([]); // State for inline title editing const isEditingTitle = Cell.of(false); // State for expanded note previews (tracks which note indices have full content shown) const _expandedPreviews = Cell.of([]); // Filter to find all notebooks (using 📓 prefix in NAME) const notebooks = computed(() => allCharms.filter((charm: any) => { const name = charm?.[NAME]; return typeof name === "string" && name.startsWith("📓"); }) as unknown as NotebookCharm[] ); // Find parent notebooks (notebooks that contain this notebook in their notes) // This creates breadcrumb navigation const parentNotebooks = computed(() => { const currentTitle = title; return notebooks.filter((nb: any) => { // Skip self const nbName = nb?.[NAME]; if (typeof nbName === "string" && nbName.includes(currentTitle)) { return false; } // Check if this notebook's notes contain the current notebook const nbNotes = nb?.notes ?? []; return nbNotes.some((n: any) => { const noteName = n?.[NAME]; return typeof noteName === "string" && noteName.includes(currentTitle); }); }); }); // Child notebooks (notebooks that are in this notebook's notes) // Use isNotebook property (not [NAME] which doesn't work on nested refs) const childNotebooks = lift( (args: { notesList: unknown[]; notebookList: unknown[] }) => { // Get titles of notebooks in our notes list (use isNotebook property, not [NAME]) const childTitles = (args.notesList ?? []) .filter((n: any) => n?.isNotebook === true) .map((n: any) => n?.title) .filter((t: any) => typeof t === "string"); // Find matching notebooks from allCharms by title return args.notebookList.filter((nb: any) => { const nbTitle = nb?.title; return typeof nbTitle === "string" && childTitles.includes(nbTitle); }); }, )({ notesList: notes, notebookList: notebooks }); // Sibling notebooks (other notebooks from space, excluding current, parents, and children) const siblingNotebooks = lift( (args: { currentTitle: string; parentList: unknown[]; childList: unknown[]; notebookList: unknown[]; }) => { // Use title property for matching (works on nested refs) const parentTitles = args.parentList .map((p: any) => p?.title) .filter((t: any) => typeof t === "string"); const childTitles = args.childList .map((c: any) => c?.title) .filter((t: any) => typeof t === "string"); return args.notebookList.filter((nb: any) => { const nbTitle = nb?.title; if (typeof nbTitle !== "string") return false; // Skip current notebook if (nbTitle === args.currentTitle) return false; // Skip parent notebooks if (parentTitles.includes(nbTitle)) return false; // Skip child notebooks (they're shown in their own section) if (childTitles.includes(nbTitle)) return false; return true; }); }, )({ currentTitle: title, parentList: parentNotebooks, childList: childNotebooks, notebookList: notebooks, }); // Use lift() for proper reactive tracking of notebook list lengths const hasParentNotebooks = lift((args: { list: unknown[] }) => args.list.length > 0 )({ list: parentNotebooks }); const hasSiblingNotebooks = lift((args: { list: unknown[] }) => args.list.length > 0 )({ list: siblingNotebooks }); // Check if "All Notes" charm exists in the space const allNotesCharm = computed(() => allCharms.find((charm: any) => { const name = charm?.[NAME]; return typeof name === "string" && name.startsWith("All Notes"); }) ); // Computed items for ct-select dropdowns (notebooks + "New Notebook...") // ct-select has proper bidirectional DOM sync, unlike native