/// import { Cell, computed, type Default, generateText, handler, NAME, navigateTo, pattern, patternTool, Stream, UI, type VNode, wish, } from "commontools"; // 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 MinimalCharm = { [NAME]?: string; }; type NotebookCharm = { [NAME]?: string; notes?: NoteCharm[]; }; type NoteCharm = { [NAME]?: string; noteId?: string; }; type Input = { title?: Cell>; content?: Cell>; isHidden?: Default; noteId?: Default; /** Pattern JSON for [[wiki-links]]. Defaults to creating new Notes. */ linkPattern?: Cell>; }; /** Represents a small #note a user took to remember some text. */ type Output = { mentioned: Default, []>; backlinks: MentionableCharm[]; content: Default; isHidden: Default; noteId: Default; grep: Stream<{ query: string }>; translate: Stream<{ language: string }>; editContent: Stream<{ detail: { value: string } }>; /** Minimal UI for embedding in containers like Record. Use via ct-render variant="embedded". */ embeddedUI: VNode; }; const _updateTitle = handler< { detail: { value: string } }, { title: Cell } >( (event, state) => { state.title.set(event.detail?.value ?? ""); }, ); const _updateContent = handler< { detail: { value: string } }, { content: Cell } >( (event, state) => { state.content.set(event.detail?.value ?? ""); }, ); const handleCharmLinkClick = handler< { detail: { charm: Cell; }; }, Record >(({ detail }, _) => { return navigateTo(detail.charm); }); const handleNewBacklink = handler< { detail: { text: string; charmId: any; charm: Cell; navigate: boolean; }; }, { mentionable: Cell; } >(({ detail }, { mentionable }) => { console.log("new charm", detail.text, detail.charmId); if (detail.navigate) { return navigateTo(detail.charm); } else { mentionable.push(detail.charm as unknown as MentionableCharm); } }); /** This edits the content */ const handleEditContent = handler< { detail: { value: string }; result?: Cell }, { content: Cell } >( ({ detail, result }, { content }) => { content.set(detail.value); result?.set("test!"); }, ); const handleCharmLinkClicked = handler }>( (_, { charm }) => { return navigateTo(charm); }, ); // 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); } }); // Toggle dropdown menu const toggleMenu = handler }>( (_, { menuOpen }) => menuOpen.set(!menuOpen.get()), ); // Close dropdown menu const closeMenu = handler }>( (_, { menuOpen }) => menuOpen.set(false), ); // Create new note (adds to parent notebook if present) const createNewNote = handler< void, { allCharms: Cell; parentNotebook: Cell; } >((_, { allCharms, parentNotebook }) => { const notebook = parentNotebook?.get?.(); const note = Note({ title: "New Note", content: "", noteId: generateId(), isHidden: !!notebook, // Hide from default-app if in a notebook }); allCharms.push(note as unknown as MinimalCharm); // Add to parent notebook using Cell.key() pattern if (notebook) { const charmsList = allCharms.get(); const nbName = (notebook as any)?.[NAME]; const nbIndex = charmsList.findIndex((c: any) => (c as any)?.[NAME] === nbName ); if (nbIndex >= 0) { const notebookCell = allCharms.key(nbIndex); const notesCell = notebookCell.key("notes") as Cell; notesCell.push(note as unknown as NoteCharm); } } return navigateTo(note); }); // Menu: Navigate to a notebook const menuGoToNotebook = handler< void, { menuOpen: Cell; notebook: Cell } >((_, { menuOpen, notebook }) => { menuOpen.set(false); return navigateTo(notebook); }); // Navigate to parent notebook const goToParent = handler }>( (_, { parent }) => navigateTo(parent), ); // Menu: All Notes (find existing only - can't create due to circular imports) const menuAllNotebooks = handler< void, { menuOpen: Cell; allCharms: Cell } >((_, { menuOpen, allCharms }) => { menuOpen.set(false); 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); } // Can't create NotesImportExport here due to circular imports // User should create it from default-app first }); const Note = pattern( ({ title, content, isHidden, noteId, linkPattern }) => { const { allCharms } = wish<{ allCharms: MinimalCharm[] }>("/"); const mentionable = wish>( "#mentionable", ); const recentCharms = wish("#recent"); const mentioned = Cell.of([]); // Dropdown menu state const menuOpen = Cell.of(false); // State for inline title editing const isEditingTitle = Cell.of(false); // 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[] ); // 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"); }) ); // Compute which notebooks contain this note by noteId const containingNotebookNames = computed(() => { const myId = noteId; // CTS handles Cell unwrapping if (!myId) return []; // Can't match if we have no noteId const result: string[] = []; for (const nb of notebooks) { const nbNotes = (nb as any)?.notes ?? []; const nbName = (nb as any)?.[NAME] ?? ""; for (const n of nbNotes) { if (n?.noteId && n.noteId === myId) { result.push(nbName); break; } } } return result; }); // Compute actual notebook references that contain this note const containingNotebooks = computed(() => { const myId = noteId; // CTS handles Cell unwrapping if (!myId) return []; // Can't match if we have no noteId return notebooks.filter((nb) => { const nbNotes = (nb as any)?.notes ?? []; return nbNotes.some((n: any) => n?.noteId && n.noteId === myId); }); }); // Find parent notebook: prioritize most recently visited, fall back to first const parentNotebook = computed(() => { if (containingNotebooks.length === 0) return null; // Find most recent notebook that contains this note for (const recent of (recentCharms ?? [])) { const name = (recent as any)?.[NAME]; if (typeof name === "string" && name.startsWith("📓")) { if (containingNotebooks.some((nb: any) => nb?.[NAME] === name)) { return recent; } } } // Fall back to first containing notebook return containingNotebooks[0]; }); // populated in backlinks-index.tsx const backlinks = Cell.of([]); // Use provided linkPattern or default to creating new Notes // linkPattern is a Cell - access reactively, not as raw string const patternJson = computed(() => { // deno-lint-ignore no-explicit-any const lpValue = (linkPattern as any)?.get?.() ?? linkPattern; const custom = typeof lpValue === "string" ? lpValue.trim() : ""; return custom || JSON.stringify(Note); }); // Editor component - used in both full UI and embeddedUI const editorUI = ( ); return { [NAME]: computed(() => `📝 ${title.get()}`), [UI]: ( {/* Parent notebook button */} (parentNotebook ? "flex" : "none")), alignItems: "center", padding: "6px 8px", fontSize: "16px", }} title={computed(() => `Go to ${parentNotebook?.[NAME] ?? "notebook"}` )} > ⬆️ {/* Editable Title - click to edit */}
isEditingTitle.get() ? "none" : "flex" ), alignItems: "center", gap: "8px", cursor: "pointer", flex: 1, }} onClick={startEditingTitle({ isEditingTitle })} > {title}
isEditingTitle.get() ? "flex" : "none" ), flex: 1, marginRight: "12px", }} >
{/* New Note button */} 📝 New Notebooks {"\u25BE"} {/* Backdrop to close menu when clicking outside */}
(menuOpen.get() ? "block" : "none")), position: "fixed", inset: "0", zIndex: "999", }} /> {/* Dropdown Menu */} (menuOpen.get() ? "flex" : "none")), position: "fixed", top: "112px", right: "16px", background: "var(--ct-color-bg, white)", border: "1px solid var(--ct-color-border, #e5e5e7)", borderRadius: "12px", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", minWidth: "180px", zIndex: "1000", padding: "4px", }} > {/* List of notebooks with ✓ for membership */} {notebooks.map((notebook) => ( {"\u00A0\u00A0"} {notebook?.[NAME] ?? "Untitled"} {computed(() => { const nbName = (notebook as any)?.[NAME] ?? ""; return containingNotebookNames.includes(nbName) ? " ✓" : ""; })} ))} {/* Divider + All Notes - only show if All Notes charm exists */}
allNotesCharm ? "block" : "none"), height: "1px", background: "var(--ct-color-border, #e5e5e7)", margin: "4px 8px", }} /> allNotesCharm ? "flex" : "none"), justifyContent: "flex-start", }} > {"\u00A0\u00A0"}📁 All Notes {editorUI} {backlinks?.map((charm) => ( {charm?.[NAME]} ))} ), title, content, mentioned, backlinks, isHidden, noteId, grep: patternTool( ({ query, content }: { query: string; content: string }) => { return computed(() => { return content.split("\n").filter((c) => c.includes(query)); }); }, { content }, ), translate: patternTool( ( { language, content }: { language: string; content: string; }, ) => { const genResult = generateText({ system: computed(() => `Translate the content to ${language}.`), prompt: computed(() => `${content}`), }); return computed(() => { if (genResult.pending) return undefined; if (genResult.result == null) return "Error occured"; return genResult.result; }); }, { content }, ), editContent: handleEditContent({ content }), // Minimal UI for embedding in containers (e.g., Record modules) embeddedUI: editorUI, }; }, ); export default Note;