import { action, computed, Default, equals, handler, NAME, navigateTo, pattern, SELF, Stream, UI, type VNode, wish, Writable, } from "commonfabric"; import Note from "./note.tsx"; import { getPieceName, type MentionablePiece, type MinimalPiece, type NotebookInput, type NotebookPiece, type NotePiece, } from "./schemas.tsx"; // ===== Shared Utility Functions ===== /** * Remove items from all notebooks' notes arrays using equals() for identity comparison. * Optionally skip a target notebook index (for move operations). */ const removeFromAllNotebooks = ( notebooks: Writable, itemsToRemove: ( | NotePiece | NotebookPiece | Writable | Writable )[], skipIndex?: number, ): void => { const notebooksList = notebooks.get(); for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { if (skipIndex !== undefined && nbIdx === skipIndex) continue; const nbNotesCell = notebooks.key(nbIdx).key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter( (n) => !itemsToRemove.some((item) => equals(n, item)), ); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } }; // ===== Output Type ===== /** A #notebook that organizes notes into collections. */ export interface NotebookOutput extends NotebookPiece { [NAME]: string; [UI]: VNode; title: string; notes: NotePiece[]; noteCount: number; summary: string; isNotebook: boolean; isHidden: boolean; backlinks: MentionablePiece[]; // LLM-callable streams for omnibot integration createNote: Stream<{ title: string; content: string }>; createNotes: Stream<{ notesData: Array<{ title: string; content: string }> }>; setTitle: Stream; createNotebook: Stream<{ title: string; notesData?: Array<{ title: string; content: string }>; }>; // Test-accessible action streams selectAllNotes: Stream; deselectAllNotes: Stream; deleteSelected: Stream; duplicateSelected: Stream; showNewNoteModal: Stream; cancelNewNote: Stream; showNewNotebookModal: Stream; cancelNewNotebook: Stream; cancelNewNestedNotebook: Stream; startEditTitle: Stream; stopEditTitle: Stream; goToAllNotes: Stream; createNoteFromPrompt: Stream; createAnotherNoteFromPrompt: Stream; createNestedNotebookFromPrompt: Stream; createAnotherNestedNotebookFromPrompt: Stream; createNotebookFromSelectionPrompt: Stream; // Test-accessible state selectedNoteIndices: number[]; selectedCount: number; hasSelection: boolean; showNewNotePrompt: boolean; showNewNotebookPrompt: boolean; showNewNestedNotebookPrompt: boolean; isEditingTitle: boolean; } // Handler to remove a note from this notebook (but keep it in the space) const removeFromNotebook = handler< void, { note: Writable; notes: Writable } >((_, { note, notes }) => { const notebookNotes = notes.get(); const index = notebookNotes.findIndex((n) => 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 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: Writable } }, { notes: Writable; notebooks: Writable; selectedNoteIndices: Writable; } >((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 const draggedIndex = notesList.findIndex((n) => equals(sourceCell, n)); const isDraggedInSelection = draggedIndex >= 0 && selected.includes(draggedIndex); if (isDraggedInSelection && selected.length > 1) { // Multi-item move const itemsToMove = selected.map((idx) => notes.key(idx)); // Remove from all notebooks removeFromAllNotebooks(notebooks, itemsToMove); // Add all to this notebook, deduplicated by link identity on the server. // The whole-list read above genuinely has to stay (it resolves the dragged // index against the selection), so this write still contends on concurrent // notes changes; addUnique keeps a re-add a no-op instead of a duplicate. for (const item of itemsToMove) { notes.addUnique(item); item.key("isHidden").set(true); } selectedNoteIndices.set([]); } else { // Single-item move if (notesList.some((n) => equals(sourceCell, n))) return; removeFromAllNotebooks(notebooks, [sourceCell]); sourceCell.key("isHidden").set(true); // Deduplicated by link identity; the early return above already covers the // common already-present case, and the retained read keeps this contended. notes.addUnique(sourceCell); } }); // 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: Writable } }, { targetNotebook: Writable<{ title?: string; notes?: NotePiece[]; isNotebook?: boolean; }>; currentNotes: Writable; selectedNoteIndices: Writable; notebooks: Writable; } >((event, { targetNotebook, currentNotes, selectedNoteIndices, notebooks }) => { const sourceCell = event.detail.sourceCell; // Check if target is actually a notebook if (!targetNotebook.key("isNotebook").get()) return; const targetNotesCell = targetNotebook.key("notes"); const currentList = currentNotes.get(); const selected = selectedNoteIndices.get(); // The only binding today passes the notebook's own SELF as the target, so // target and current usually alias the same notes collection. In that case // there is nothing to remove from "current": removing would strip or // reorder the very memberships addUnique confirms below. const targetIsCurrent = equals(targetNotesCell, currentNotes); // Check if dragged item is in the selection const draggedIndex = currentList.findIndex((n) => equals(sourceCell, n)); const isDraggedInSelection = draggedIndex >= 0 && selected.includes(draggedIndex); if (isDraggedInSelection && selected.length > 1) { // Multi-item move const itemsToMove = selected.map((idx) => currentNotes.key(idx)).filter( Boolean, ); // Add all to target, deduplicated by link identity on the server — // addUnique needs no read of the target list, so concurrent drops onto the // same notebook merge instead of conflicting. for (const item of itemsToMove) { targetNotesCell.addUnique(item); item.key("isHidden").set(true); } // Find target notebook index to skip it during removal const notebooksList = notebooks.get(); const targetIndex = notebooksList.findIndex( (nb) => equals(nb, targetNotebook), ); // Remove from all notebooks except target removeFromAllNotebooks(notebooks, itemsToMove, targetIndex); // Remove from current notebook — unless current IS the target, where // "removing" would strip the memberships this drop just confirmed. (The // pre-addUnique code ran this unconditionally, so a same-notebook // multi-drop silently dropped the selected notes from the notebook.) if (!targetIsCurrent) { currentNotes.set( currentList.filter( (n) => !itemsToMove.some((item) => equals(n, item)), ), ); } selectedNoteIndices.set([]); } else { // Single-item move // Remove from current notebook if present — unless current IS the // target, where the removal would turn addUnique's no-op below into a // move-to-tail reorder of an already-present note. if (!targetIsCurrent) { const indexInCurrent = currentList.findIndex((n) => equals(sourceCell, n) ); if (indexInCurrent !== -1) { const copy = [...currentList]; copy.splice(indexInCurrent, 1); currentNotes.set(copy); } } sourceCell.key("isHidden").set(true); // Deduplicated by link identity on the server, so a note the target // already holds keeps a single membership with no whole-list read. For a // genuinely distinct target this completes the move (the old guard // skipped it entirely, leaving the note in both notebooks). targetNotesCell.addUnique(sourceCell); } }); // Handler for clicking on a backlink const handleBacklinkClick = handler< void, { piece: Writable } >( (_, { piece }) => navigateTo(piece), ); // Handler to navigate to a child (note or notebook) - sets parent for back navigation // Must be module-scope handler because it's used in .map() with per-iteration child bindings const navigateToChild = handler< void, { child: Writable; self: NotebookPiece } >( (_, { child, self }) => { child.key("parentNotebook").set(self); navigateTo(child); }, ); // Handler to permanently delete selected notes from the space const deleteSelectedNotes = handler< void, { notes: Writable; selectedNoteIndices: Writable; pieceRegistry: Writable>; notebooks: Writable; } >((_, { notes, selectedNoteIndices, pieceRegistry, notebooks }) => { const selected = selectedNoteIndices.get(); const notesList = notes.get(); const itemsToDelete = selected.map((idx) => notesList[idx]).filter(Boolean); // Remove from all notebooks removeFromAllNotebooks(notebooks, itemsToDelete); // Remove from this notebook's notes array notes.set( notesList.filter( (n) => !itemsToDelete.some((item) => equals(n, item)), ), ); // Remove from the registry (permanent delete) pieceRegistry.set( (pieceRegistry.get() ?? []).filter( (piece) => !itemsToDelete.some((item) => equals(piece, item)), ), ); selectedNoteIndices.set([]); }); // Handler to add selected notes to another notebook const addSelectedToNotebook = handler< { target?: { value: string }; detail?: { value: string } }, { notes: Writable; selectedNoteIndices: Writable; notebooks: Writable; selectedAddNotebook: Writable; showNewNotebookPrompt: Writable; pendingNotebookAction: Writable<"add" | "move" | "">; } >(( event, { notes, selectedNoteIndices, notebooks, selectedAddNotebook, showNewNotebookPrompt, pendingNotebookAction, }, ) => { // Handle both native select (target.value) and cf-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: Writable = targetNotebookCell.key("notes"); // Collect notes first, then batch push (reduces N reactive cycles to 1) const notesToAdd: NotePiece[] = []; for (const idx of selected) { const note = notesList[idx]; if (note) notesToAdd.push(note); } targetNotebookNotes.pushAll(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: Writable; selectedNoteIndices: Writable; notebooks: Writable; selectedMoveNotebook: Writable; showNewNotebookPrompt: Writable; pendingNotebookAction: Writable<"add" | "move" | "">; } >(( event, { notes, selectedNoteIndices, notebooks, selectedMoveNotebook, showNewNotebookPrompt, pendingNotebookAction, }, ) => { const value = event.target?.value ?? event.detail?.value ?? ""; if (!value) return; if (value === "new") { pendingNotebookAction.set("move"); showNewNotebookPrompt.set(true); selectedMoveNotebook.set(""); return; } const nbIndex = parseInt(value, 10); if (nbIndex < 0) return; const selected = selectedNoteIndices.get(); const notesList = notes.get(); const notesToMove = selected.map((idx) => notesList[idx]).filter(Boolean); // Add to target notebook const targetNotebookNotes = notebooks.key(nbIndex).key("notes"); targetNotebookNotes.pushAll(notesToMove); // Remove from all notebooks except target removeFromAllNotebooks(notebooks, notesToMove, nbIndex); // Remove from this notebook notes.set( notesList.filter( (n) => !notesToMove.some((item) => equals(n, item)), ), ); selectedNoteIndices.set([]); selectedMoveNotebook.set(""); }); // Handler to create notebook from prompt and add/move selected notes const createNotebookFromPrompt = handler< void, { newNotebookName: Writable; showNewNotebookPrompt: Writable; pendingNotebookAction: Writable<"add" | "move" | "">; selectedNoteIndices: Writable; notes: Writable; pieceRegistry: Writable; notebooks: Writable; } >((_, state) => { const { newNotebookName, showNewNotebookPrompt, pendingNotebookAction, selectedNoteIndices, notes, pieceRegistry, notebooks, } = state; const name = newNotebookName.get().trim() || "New Notebook"; const actionType = pendingNotebookAction.get(); const selected = selectedNoteIndices.get(); const notesList = notes.get(); const selectedItems = selected.map((idx) => notesList[idx]).filter(Boolean); // Create the notebook with items included const newNotebook = Notebook({ title: name, notes: selectedItems, isHidden: true, }); pieceRegistry.push(newNotebook); if (actionType === "move") { // Remove from all existing notebooks removeFromAllNotebooks(notebooks, selectedItems); // Remove from this notebook, then add new notebook notes.set([ ...notesList.filter( (n) => !selectedItems.some((item) => equals(n, item)), ), newNotebook, ]); } else { // For add: append the new notebook as sibling. Its contents derive from // the notes snapshot read above, so write the whole list from that same // snapshot; the read stays in the conflict set and a concurrent change // rejects this commit and retries against fresh state. notes.set([...notesList, newNotebook]); } // Clean up state selectedNoteIndices.set([]); newNotebookName.set(""); pendingNotebookAction.set(""); showNewNotebookPrompt.set(false); }); // Handler to toggle checkbox selection with shift-click support const toggleNoteCheckbox = handler< { shiftKey?: boolean }, { index: number; selectedNoteIndices: Writable; lastSelectedNoteIndex: Writable; } >((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); }); const Notebook = pattern( ( { title, notes, isNotebook, isHidden, parentNotebook: _parentNotebook, [SELF]: self, }, ) => { // Ensure parentNotebook is always a Writable (input is optional) const parentNotebook = _parentNotebook ?? new Writable(null as NotebookPiece | null); // Type-based discovery for notebooks and "All Notes" piece const notebookWish = wish({ query: "#notebook", scope: ["."], headless: true, }); // Notebooks discovered through wish scope. const notebooks = notebookWish.candidates; // The registry is writable for creating notes and notebooks. const pieceRegistry = wish>({ query: "#pieceRegistry", headless: true, }).result!; // Use computed() for proper reactive tracking of notes.length const noteCount = computed(() => notes.get().length); const hasNotes = computed(() => notes.get().length > 0); const summary = computed(() => { const notesList = notes.get() ?? []; return notesList .map((note) => note?.summary ?? note?.[NAME] ?? "") .filter((s: string) => s.length > 0) .join(" | "); }); // Selection state for multi-select const selectedNoteIndices = new Writable([]); const lastSelectedNoteIndex = new Writable(-1); const selectedAddNotebook = new Writable(""); const selectedMoveNotebook = new Writable(""); // 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 = new Writable(false); const newNotebookName = new Writable(""); const pendingNotebookAction = new Writable<"add" | "move" | "">(""); // Track which action triggered the modal // State for "New Note" prompt modal const showNewNotePrompt = new Writable(false); const newNoteTitle = new Writable(""); const usedCreateAnotherNote = new Writable(false); // Track if "Create Another" was used // State for "New Nested Notebook" prompt modal (from dropdown menu) const showNewNestedNotebookPrompt = new Writable(false); const newNestedNotebookTitle = new Writable(""); const usedCreateAnotherNotebook = new Writable(false); // Track if "Create Another" was used // Backlinks - populated by backlinks-index.tsx const backlinks = new Writable([]); // State for inline title editing const isEditingTitle = new Writable(false); // ===== Actions (converted from module-scope handlers) ===== const showNewNoteModalAction = action(() => showNewNotePrompt.set(true)); const showNewNotebookModalAction = action(() => showNewNestedNotebookPrompt.set(true) ); const cancelNewNotePromptAction = action(() => { showNewNotePrompt.set(false); newNoteTitle.set(""); usedCreateAnotherNote.set(false); }); const cancelNewNestedNotebookPromptAction = action(() => { showNewNestedNotebookPrompt.set(false); newNestedNotebookTitle.set(""); usedCreateAnotherNotebook.set(false); }); const cancelNewNotebookPromptAction = action(() => { showNewNotebookPrompt.set(false); newNotebookName.set(""); pendingNotebookAction.set(""); selectedAddNotebook.set(""); selectedMoveNotebook.set(""); }); // TODO(seefeld,mathpirate): We need some better way to find the "All Notes" notebook. const goToAllNotesAction = action(() => { const pieces = pieceRegistry.get() ?? []; const existing = pieces.find((piece: any) => { const name = piece?.[NAME]; return typeof name === "string" && name.startsWith("All Notes"); }); if (existing) { return navigateTo(existing); } }); const goToParentAction = action(() => { const p = parentNotebook.get(); if (p) navigateTo(p); }); const selectAllNotesAction = action(() => { const notesList = notes.get(); selectedNoteIndices.set(notesList.map((_, i) => i)); }); const deselectAllNotesAction = action(() => { selectedNoteIndices.set([]); }); const startEditingTitleAction = action(() => isEditingTitle.set(true)); const stopEditingTitleAction = action(() => isEditingTitle.set(false)); const handleTitleKeydownAction = action((event: { key?: string }) => { if (event?.key === "Enter") { isEditingTitle.set(false); } }); // ===== Actions (close over notes, pieceRegistry, self) ===== // These work because all inputs use Default<> (not optional ?), so self // always satisfies the output schema's required properties at runtime. // LLM-callable: Create a single note in this notebook const createNoteStreamAction = action( ( { title: noteTitle, content, navigate }: { title: string; content: string; navigate?: boolean; }, ) => { const newNote = Note({ title: noteTitle, content, isHidden: true, parentNotebook: self, }); pieceRegistry.push(newNote); notes.push(newNote); if (navigate) { navigateTo(newNote); } return newNote; }, ); // LLM-callable: Create multiple notes in bulk const createNotesStreamAction = action( ( { notesData }: { notesData: Array<{ title: string; content: string }>; }, ) => { const created: NotePiece[] = []; for (const data of notesData) { created.push(Note({ title: data.title, content: data.content, isHidden: true, parentNotebook: self, })); } pieceRegistry.pushAll(created); notes.pushAll(created); return created; }, ); // LLM-callable: Rename the notebook const setTitleAction = action((newTitle: string) => { title.set(newTitle); return newTitle; }); // LLM-callable: Create a new notebook (optionally with notes) const createNotebookStreamAction = action( ( { title: nbTitle, notesData }: { title: string; notesData?: Array<{ title: string; content: string }>; }, ) => { const notesToAdd: NotePiece[] = []; if (notesData && notesData.length > 0) { for (const data of notesData) { notesToAdd.push(Note({ title: data.title, content: data.content, isHidden: true, })); } } const newNotebook = Notebook({ title: nbTitle, notes: notesToAdd, }); pieceRegistry.push(newNotebook); return newNotebook; }, ); // Create note - shared logic for "Create" and "Create Another" buttons const createNoteAction = action(() => { const noteTitle = newNoteTitle.get() || "New Note"; const newNote = Note({ title: noteTitle, content: "", isHidden: true, parentNotebook: self, }); pieceRegistry.push(newNote); notes.push(newNote); // Close modal and navigate (unless "Create Another" was previously used) const shouldNavigate = !usedCreateAnotherNote.get(); showNewNotePrompt.set(false); newNoteTitle.set(""); usedCreateAnotherNote.set(false); if (shouldNavigate) { return navigateTo(newNote); } }); const createAnotherNoteAction = action(() => { const noteTitle = newNoteTitle.get() || "New Note"; const newNote = Note({ title: noteTitle, content: "", isHidden: true, parentNotebook: self, }); pieceRegistry.push(newNote); notes.push(newNote); // Keep modal open for "Create Another" usedCreateAnotherNote.set(true); newNoteTitle.set(""); }); // Create nested notebook - shared logic for "Create" and "Create Another" buttons const createNestedNotebookAction = action(() => { const nbTitle = newNestedNotebookTitle.get() || "New Notebook"; const nb = Notebook({ title: nbTitle, notes: [], isHidden: true, parentNotebook: self, }); pieceRegistry.push(nb); notes.push(nb); const shouldNavigate = !usedCreateAnotherNotebook.get(); showNewNestedNotebookPrompt.set(false); newNestedNotebookTitle.set(""); usedCreateAnotherNotebook.set(false); if (shouldNavigate) { return navigateTo(nb); } }); const createAnotherNestedNotebookAction = action(() => { const nbTitle = newNestedNotebookTitle.get() || "New Notebook"; const nb = Notebook({ title: nbTitle, notes: [], isHidden: true, parentNotebook: undefined, }); pieceRegistry.push(nb); notes.push(nb); usedCreateAnotherNotebook.set(true); newNestedNotebookTitle.set(""); }); // Action to duplicate selected notes const doDuplicateSelectedNotes = action(() => { const selected = selectedNoteIndices.get(); for (const idx of selected) { const original = notes.key(idx); if (original) { const newNote = Note({ title: (original.get().title ?? "Note") + " (Copy)", content: original.get().content ?? "", isHidden: true, parentNotebook: self, // Set parent for back navigation }); pieceRegistry.push(newNote); notes.push(newNote); } } selectedNoteIndices.set([]); }); // COMBINED computed for ALL notebook relationships to avoid nested computed access issues // Returns parents, children, siblings, and their boolean flags all in one place // NOTE: Prefixed with _ because siblings feature is disabled; kept for re-enabling const _notebookRelationships = computed(() => { // Current notebook's name for comparison - use title since it's our local prop const currentTitle = title; // Build a pattern to match our NAME format: "📓 {title} ({count})" const _currentNamePattern = `📓 ${currentTitle}`; const nbCount = notebooks.length; // Find parent notebooks (notebooks that contain this notebook in their notes) // For now, skip parent detection as it requires accessing notes arrays of other pieces const parents: NotebookPiece[] = []; // Note: Parent detection requires deeper integration with piece introspection // Find child notebooks (notebooks in our notes list) const notesList = notes.get() ?? []; const childNames = (Array.isArray(notesList) ? notesList : []) .filter((n) => n?.isNotebook === true) .map((n) => getPieceName(n)) .filter((t) => t.length > 0); const children: (NotePiece | NotebookPiece)[] = []; for (let i = 0; i < nbCount; i++) { const nb = notebooks[i]; const nbName = getPieceName(nb); if ( childNames.some((cn) => nbName.includes(cn) || cn.includes(nbName)) ) { children.push(nb); } } // ======================================================================== // SIBLINGS FEATURE DISABLED FOR PERFORMANCE // ======================================================================== // The "Other notebooks" sibling feature was causing significant performance // issues with 50+ notebooks due to O(n*m) complexity in the computation. // // To re-enable: // 1. Uncomment the siblings computation below // 2. Uncomment the UI section in JSX (search for "DISABLED FOR PERFORMANCE") // 3. Set hasSiblings to: siblings.length > 0 // // Performance consideration: Consider implementing lazy evaluation or // a collapsible UI that only computes siblings when expanded. // ======================================================================== // const notesListNames = (Array.isArray(notesList) ? notesList : []) // .map((n: any) => getPieceName(n)) // .filter((t: any) => t.length > 0); // // const siblings: any[] = []; // for (let i = 0; i < nbCount; i++) { // const nb = notebooks[i]; // const nbName = getPieceName(nb); // if (nbName.length === 0) continue; // if (nbName.startsWith(currentNamePattern)) continue; // if (notesListNames.some((n) => nbName.includes(n) || n.includes(nbName))) continue; // siblings.push(nb); // } const siblings: any[] = []; // Empty - siblings feature disabled return { parents, hasParents: parents.length > 0, children, hasChildren: children.length > 0, siblings, hasSiblings: false, // Set to: siblings.length > 0 when re-enabling }; }); // Computed items for cf-select dropdowns (notebooks + "New Notebook...") // cf-select has proper bidirectional DOM sync, unlike native