Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.
Caveat: Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.clear
feel like the @src/components/Editor/BubbleMenu.tsx needs to be split. Currently one is too much and horizontal scrolls are a lot.
Or maybe not. Open to ideas.
Most apps do follow a scrolling bubble menu, but there's a few outliers like these.
[Image #4] - Notion has a mix. It has a smaller bubble menu, with a + button that replaces the keyboard with a list of options
[Image #5] - Noteplan has similar mix. Smaller bubble menu, with a command bar that uses native menus for the more options.
[Image: source: /Users/rajatkulkarni/.claude/image-cache/4e7fef82-4832-4574-b6b5-674cdf4c1553/4.png] [Image: source: /Users/rajatkulkarni/.claude/image-cache/4e7fef82-4832-4574-b6b5-674cdf4c1553/5.png]
Good instinct — the current menu is too flat. Here's what I'd recommend, inspired by those screenshots:
Two-tier approach:
Primary bar (compact, no scrolling):
code highlight | [[ | Aa button (opens secondary)Secondary panel (opens on "Aa" tap, as a popover or sheet above the bar):
This gives you ~7-8 buttons on the primary bar (fits without scrolling on any phone), and block-level formatting lives one tap away. Similar to Notion's model but keeping inline formatting always accessible since that's what you reach for mid-sentence.
The "Aa" button could show a ChevronUp when the panel is open, making it a toggle. The panel itself could be a 2-column grid like Notion or a simple vertical list like Noteplan — I'd lean toward a compact grid since your options are fewer.
Want me to implement this?
Hold on there's also TOT that does this [Image #6]
[Image: source: /Users/rajatkulkarni/.claude/image-cache/4e7fef82-4832-4574-b6b5-674cdf4c1553/6.png]
Nice — Tot does two fixed rows, no scrolling, no hidden panels. Everything visible at a glance.
So three patterns on the table:
| App | Approach | Trade-off |
|---|---|---|
| Notion | Compact bar + expandable grid panel | More options, extra tap for blocks |
| Noteplan | Compact bar + popover/dropdown menu | Familiar, but overlays content |
| Tot | Two fixed rows | Zero taps to reach anything, but limited by screen width |
For Octarine you have ~15 actions (more than Tot's ~8), so pure two-row might get tight. But a hybrid could work well:
Row 1 (utility): Keyboard dismiss | Undo/Redo (if you add those) | [[ | Linked note | "+" expand button
Row 2 (formatting): B I S code highlight | H1 H2 H3 | lists...
Row 2 could be the only scrollable part, or the "+" swaps row 2's content between "inline" and "block" groups.
What feels right to you — lean toward the two-row Tot style, the Notion expandable panel, or the hybrid?
Actually lets do your original idea, but let's change what we have as essential and what isn't.
Take a look at the 15 we have, and also add the following:
Here's my proposed split:
Primary bar (always visible, ~7 buttons, no scroll):
code[[ wiki linkThese are mid-sentence actions you reach for without breaking flow.
Secondary panel (behind a "+" or "Aa" button):
| Category | Items |
|---|---|
| Text style | Colored text (6 colors), Highlight colors (6 colors) |
| Headings | H1, H2, H3 |
| Lists | Bullet, Ordered, Task |
| Blocks | Code block, Blockquote, Callout, Table |
| Insert | Media from files, Files from files, Linked note (+) |
| List tools | Indent, Outdent (contextual, only in lists) |
That puts 7 on the bar and ~20 in the panel. The panel could be a scrollable grid grouped by category (like Notion's "Basic blocks" sections).
One question: should highlight on the primary bar open a color picker submenu, or just toggle default highlight? I'd lean toward default toggle on tap, with the full color options living in the secondary panel under "Text style."
Does this split feel right, or would you move anything between tiers?
um here's my rec:
+ icon sticky on the left of the bubble menu.That's cleaner. So the layout is:
[+] (sticky left) | B I Code Bullet Ordered Task [[ Insert | [⌨] (sticky right)
Primary bar has 8 buttons between two sticky anchors — should fit without scrolling on most phones.
The + panel contains everything else:
A couple clarifications:
+ button?+ panel style — grid with category headers (like Notion), or a simple vertical list (like Noteplan)?For #2 — a vertical list grouped by category (like Noteplan). Reasons:
Want me to start implementing?
Actually no, do the notion like, since if users select colored text, we go and show them a grid of 6 colors, with back and forth. It's natural.
and so on.
Got it — nested grid navigation inside the + panel. Tap a category, it slides/transitions to show sub-options, with a back button.
Top level grid:
Let me implement this. I'll restructure BubbleMenu.tsx into:
+ (left) and keyboard dismiss (right)+ panel that replaces/overlays the bar with a Notion-style grid, supporting drill-down for categories with sub-optionsLet me start.
Now let me check the slash command to understand how attach/insert from files works, and the color constants.
Found 15 files
src/components/EditorHeader.tsx
src/components/Editor/extensions/video/index.tsx
src/pages/workspace/[uuid]/home.tsx
src/hooks/workspace/useWorkspaceFileActions.tsx
src/components/Editor/extensions/image/index.tsx
src/components/Editor/extensions/video.tsx
src/components/Editor/extensions/plugins/wikilinks.tsx
src/components/Common/CommandBar/useCommandFilter.ts
src/hooks/watcher/useWorkspaceWatcher.tsx
src/components/Editor/extensions/wikilinks.tsx
src/components/Editor/extensions/slash-command.tsx
src/types/index.ts
src/hooks/workspace/useFileActions.tsx
src/constants/workspace.ts
src/helpers/onboarding.ts
[result-id: r3]No matches found48-} from "@heroicons/react/24/outline";
49-import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
50-import { useSelector } from "react-redux";
51-import { RootState } from "~/redux/reducers";
52-import useDateFormatting from "~/hooks/common/useDateFormatting";
53:import useFileActions from "~/hooks/workspace/useFileActions";
54-import { MERMAID_DEFAULT_CONTENT } from "~/constants/language";
55-import { sep } from "@tauri-apps/api/path";
56-
57-interface CommandItemProps {
58- title: string;
--
479- .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
480- .run(),
481- },
482- {
483- title: "Link Notes/Media/Files",
484: searchTerms: ["link", "wiki", "notes", "image", "video", "attach"],
485- icon: <DocumentIcon className={iconClasses} />,
486- command: ({ editor, range }: CommandProps) => {
487- editor
488- .chain()
489- .focus()
[result-id: r4]/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-12-} from "@heroicons/react/24/outline";
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-13-import { CalloutType } from "../callout";
src/components/Editor/extensions/callout/CalloutNodeView.tsx:14:import { getInfoBannerColor } from "~/components/Common/InfoBanner";
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-15-import { useTheme } from "next-themes";
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-16-import Sheet from "~/components/Common/Sheet";
--
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-46-};
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-47-
src/components/Editor/extensions/callout/CalloutNodeView.tsx:48:const calloutTypes: CalloutType[] = [
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-49- "info",
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-50- "warning",
--
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-74- const colors = useMemo(
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-75- () =>
src/components/Editor/extensions/callout/CalloutNodeView.tsx:76: getInfoBannerColor(
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-77- type as "info" | "warning" | "error" | "success",
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-78- theme,
--
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-151- >
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-152- <div className="flex flex-col gap-2 mb-4">
src/components/Editor/extensions/callout/CalloutNodeView.tsx:153: {calloutTypes.map((calloutType) => {
src/components/Editor/extensions/callout/CalloutNodeView.tsx:154: const OptionIcon = calloutIcons[calloutType];
src/components/Editor/extensions/callout/CalloutNodeView.tsx:155: const isSelected = calloutType === type;
src/components/Editor/extensions/callout/CalloutNodeView.tsx:156: const optionColors = getInfoBannerColor(
src/components/Editor/extensions/callout/CalloutNodeView.tsx:157: calloutType as "info" | "warning" | "error" | "success",
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-158- theme,
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-159- );
--
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-161- return (
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/callout/CalloutNodeView.tsx-162- <button
[Showing results with pagination = limit: 30]
[result-id: r5]Now I have enough context. Let me check how the slash command inserts callouts and how file attachment works, then implement.
360- .chain()
361- .focus()
362- .deleteRange(range)
363- .toggleNode("paragraph", "paragraph")
364- .toggleBlockquote()
365- .run(),
366- },
367- {
368: title: "Info Callout",
369: searchTerms: ["callout", "info", "information", "note"],
370- icon: <InformationCircleIcon className={iconClasses} />,
371- command: ({ editor, range }: CommandProps) =>
372- (editor.chain().focus().deleteRange(range) as any)
373: .setCallout({ type: "info" })
374- .run(),
375- },
376- {
377: title: "Warning Callout",
378: searchTerms: ["callout", "warning", "caution", "warn"],
379- icon: <ExclamationTriangleIcon className={iconClasses} />,
380- command: ({ editor, range }: CommandProps) =>
381- (editor.chain().focus().deleteRange(range) as any)
382: .setCallout({ type: "warning" })
383- .run(),
384- },
385- {
386: title: "Error Callout",
387: searchTerms: ["callout", "error", "danger", "alert"],
388- icon: <XCircleIcon className={iconClasses} />,
389- command: ({ editor, range }: CommandProps) =>
390- (editor.chain().focus().deleteRange(range) as any)
391: .setCallout({ type: "error" })
392- .run(),
393- },
394- {
395: title: "Success Callout",
396: searchTerms: ["callout", "success", "check", "done"],
397- icon: <CheckCircleIcon className={iconClasses} />,
398- command: ({ editor, range }: CommandProps) =>
399- (editor.chain().focus().deleteRange(range) as any)
[Showing results with pagination = limit: 40]
[result-id: r6]587- .chain()
588- .focus()
589- .deleteRange(range)
590- .insertContent(formatDate(new Date(), true))
591- .run(),
592- separator: true,
593- },
594- {
595: title: "Text Colors",
596- searchTerms: ["color", "text color", "font color"],
597- icon: <Palette className={iconClasses} />,
598- children: TEXT_COLORS.map((color, index) => ({
599- title: color.name,
600- searchTerms: color.searchTerms,
601- icon:
602- color.colorValue === null ? (
603- <span className="mr-2 flex h-4 w-4 items-center justify-center">
--
628- .setMark("coloredText", { color: color.colorValue })
629- .run();
630- }
631- },
632- separator: index === TEXT_COLORS.length - 1,
633- })),
634- },
635- {
636: title: "Text Highlights",
637- searchTerms: ["highlight", "background", "mark"],
638- icon: <Highlighter className={iconClasses} />,
639- children: HIGHLIGHT_COLORS.map((color, index) => ({
640- title: color.name,
641- searchTerms: color.searchTerms,
642- icon:
643- color.colorValue === null ? (
644- <span className="mr-2 flex h-4 w-4 items-center justify-center rounded bg-transparent text-[16px] font-semibold text-tertiary">
[result-id: r7]109-const iconClasses =
110- "mr-2 h-4 w-4 flex-shrink-0 text-icon stroke-[1.5] group-hover:text-primary";
111-
112:const TEXT_COLORS = [
113- {
114- name: "No Color",
115- searchTerms: ["no color", "clear color", "remove color"],
--
166- },
167-];
168-
169:const HIGHLIGHT_COLORS = [
170- {
171- name: "No Highlight",
172- searchTerms: ["no highlight", "clear highlight", "remove highlight"],
--
595- title: "Text Colors",
596- searchTerms: ["color", "text color", "font color"],
597- icon: <Palette className={iconClasses} />,
598: children: TEXT_COLORS.map((color, index) => ({
599- title: color.name,
600- searchTerms: color.searchTerms,
601- icon:
--
629- .run();
630- }
631- },
632: separator: index === TEXT_COLORS.length - 1,
633- })),
634- },
635- {
636- title: "Text Highlights",
637- searchTerms: ["highlight", "background", "mark"],
638- icon: <Highlighter className={iconClasses} />,
639: children: HIGHLIGHT_COLORS.map((color, index) => ({
640- title: color.name,
641- searchTerms: color.searchTerms,
642- icon:
--
668- .run();
669- }
670- },
671: separator: index === HIGHLIGHT_COLORS.length - 1,
672- })),
673- },
674- ];
[result-id: r8]Now let me check how file attachment from the OS file picker works (for the "Insert" button on primary bar).
Found 6 files
src/components/Tree/Entity.tsx
src/pages/workspace/[uuid]/notes.tsx
src/components/Common/CommandBar/useCommandFilter.ts
src/components/EditorActions.tsx
src/components/Editor/extensions/wikilinks.tsx
src/hooks/workspace/useFileActions.tsx225- // errorToast(`Failed to rename folder - ${e}`);
226- throw e;
227- }
228- };
229-
230: const createAttachmentInDatabase = async (file: TTableFile) => {
231- await addFileToDatabase(file);
232- };
233-
234- const deleteFile = async (id: string) => {
235- if (!currentWorkspace) {
--
636- duplicateNote,
637- createNewTemplate,
638- saveFileChanges,
639- createNoteInPredefinedFolder,
640- updateFileReferences,
641: createAttachmentInDatabase,
642- importExternalFileToWorkspace,
643- pinNote,
644- unpinNote,
645- };
646-};
[result-id: r11]206- <>
207- <OctarineMenu
208- items={menuItems}
209- open={showOptionsSheet}
210- onOpenChange={onOptionsSheetChange}
211- title={displayName}
212- description={resolvedPath}
213- />
214-
215- <OctarineDialog
216: open={showRenameDialog}
217: onOpenChange={setShowRenameDialog}
218- title="Rename Note"
219- trigger={<></>}
220- >
221- <input
222- type="text"
223- value={editingName}
224- onChange={(e) => {
225- setEditingName(e.target.value);
226- if (renameError) setRenameError("");
227- }}
--
265- Save
266- </button>
267- </div>
268- </OctarineDialog>
269-
270- <OctarineAlertDialog
271- title={`Delete ${fileName} note?`}
272- description="Are you sure you want to delete this file? This action cannot be undone."
273- confirmText="Yes, Delete"
274- onClickConfirm={handleDeleteConfirm}
275: open={showDeleteDialog}
276: setOpen={setShowDeleteDialog}
277- loading={isDeleting}
278- destructive
279- />
280- </>
281- );
282-};
283-
284-export default EditorActions;
[result-id: r12]483- const file: TTableFile = {
484- id,
485- category: extractedData.category,
486- file_type: "file",
487- name: extractedData.name,
488- };
489-
490- await addFileToDatabase(file);
491- }
492-
493- await createOrUpdateTs(ts);
494- await createOrUpdateMetadata(metadata);
495- await createOrUpdateFrontmatter(frontmatterData);
496- } catch (e) {
497- console.error('Failed to save file changes:', e, { id, workspacePath: currentWorkspace?.path });
498- // errorToast(`Failed to save changes - ${e}`);
499- throw e;
500- }
501- };
502-
503: const importExternalFileToWorkspace = async (filePath: string) => {
504- if (!currentWorkspace) {
505- throw new Error('No workspace selected');
506- }
507-
508- try {
509- const workspacePath = await resolveAndUpdateWorkspacePath(currentWorkspace, dispatch);
510- const res: TTableFile = await invoke("import_file_to_workspace", {
511- filePath,
512- workspacePath: workspacePath,
513- });
514-
515- await addFileToDatabase(res);
516-
517- return res;
518- } catch (e) {}
519- };
520-
521- const createNoteInPredefinedFolder = async (id: string) => {
522- if (!currentWorkspace) {
523- throw new Error('No workspace selected');
--
622- throw e;
623- }
624- };
625-
626- return {
627- createNewNote,
628- createNewFolder,
629- renameFile,
630- renameFolder,
631- deleteFile,
632- refreshWorkspace,
633- deleteFolder,
634- moveFile,
635- moveFolder,
636- duplicateNote,
637- createNewTemplate,
638- saveFileChanges,
639- createNoteInPredefinedFolder,
640- updateFileReferences,
641- createAttachmentInDatabase,
642: importExternalFileToWorkspace,
643- pinNote,
644- unpinNote,
645- };
646-};
647-
648-export default useFileActions;
[result-id: r13]Now I have enough context. Let me check if there's a file picker API available (Tauri dialog).
No matches foundFound 15 files limit: 15
src/components/EditorHeader.tsx
src/components/NoteInfoSheet.tsx
src/pages/workspace/[uuid]/editor.tsx
src/pages/workspace/[uuid]/settings/files.tsx
src/components/Tree/index.tsx
src/components/Tree/Entity.tsx
src/components/Common/OctarineDialog.tsx
src/components/Editor/LinkContainer.tsx
src/components/Editor/extensions/link/index.tsx
src/components/Common/CommandBar/index.tsx
src/components/Common/CommandBar/actions/useNoteActions.tsx
src/components/Common/CommandBar/CommandResults.tsx
src/components/Tree/Button.tsx
src/components/Common/CommandBar/FileResult.tsx
src/components/Common/CommandBar/actions/useCreateActions.tsx
[result-id: r14]This is a mobile app (iOS/Android via Tauri), so native file picker dialog may not be available the same way. For the "Insert" (attach) button, I'll just trigger [[ insertion like the slash command does — it opens the wikilink search which includes media and files. That's the existing pattern.
Now let me implement the redesigned BubbleMenu.
Now let me write the new BubbleMenu. I'll restructure it with:
[+] sticky left | B I Code Bullet Ordered Task [[ Insert | [⌨] sticky right+ panel: Notion-style grid with drill-down for categories with sub-optionsNow let me write the new BubbleMenu. I'll restructure it with:
[+] sticky left | B I Code Bullet Ordered Task [[ Insert | [⌨] sticky right+ panel: Notion-style grid with drill-down navigationNow let me verify it compiles.
>npx tsc --noEmit --pretty 2>&1 | head -40The errors are all pre-existing in NoteInfoSheet.tsx, not from my changes. Let me confirm no BubbleMenu errors:
>npx tsc --noEmit --pretty 2>&1 | grep -i "BubbleMenu"No errors from BubbleMenu. The implementation is done. Here's a summary:
Primary bar (single row, no scrolling):
[+] | B I code Bullet Ordered Task [[ Attach | [⌨]
+ panel (Notion-style grid, opens above the bar):
The + button toggles the panel. Sub-categories (Headings, Highlight, Text Color, Callout, Table) drill down with a back button. Direct actions (Strikethrough, Code Block, Blockquote) toggle and close the panel. The [[ and Attach buttons both insert [[ to trigger the wikilink search which covers notes, media, and files.
needs polishing, but I thought we would hide the keyboard and have it be BELOW the bubblemenu like Notion's was.
Ah right — when the + panel opens, it should dismiss the keyboard and the panel shows below the primary bar (where the keyboard was), not above it. Like Notion's block picker replacing the keyboard area.
That's a bigger behavioral change — the menu stays at the same position but the keyboard goes away and the panel fills that space underneath. Let me rethink the positioning logic.
Key changes:
+ calls editor.view.dom.blur() to dismiss keyboardNow I need to understand the keyboard detection hook to know how to keep the menu visible when keyboard is dismissed but panel is open.
hold on before you go ahead. if you do a blur, where would whatever we press get inserted?
Great catch. If we blur the editor to dismiss the keyboard, the editor loses focus and the selection/cursor position is lost. Any formatting commands or insertions would have nowhere to go.
This is the core tension: we need the keyboard gone but the editor selection preserved.
Options:
Save selection before blur, restore on action — store editor.state.selection when opening the panel, then before any command do editor.chain().focus().setTextSelection(savedSelection)...run(). This might briefly flash the keyboard back.
Don't blur — use native iOS inputAccessoryView approach — but we're in a webview so that's not directly available.
Keep keyboard open, show panel above it — what we had before, but that's the opposite of what you want.
Blur but keep selection via editor.commands.blur() — TipTap's own blur preserves the selection state internally. Then when running a command, editor.chain().focus() restores it. The keyboard would flash back momentarily when an action is taken.
Option 4 is probably the cleanest. TipTap tracks the last selection even when blurred, and .focus() restores it. The keyboard would only reappear if the action requires further typing (which is correct behavior). For things like toggling bold on a selection, it would flash focus, apply, and we could immediately blur again.
Does that trade-off work, or is the keyboard flash a dealbreaker?
lets do 4
Now I understand the full picture. The menu visibility is tied to keyboardInfo.isVisible, so when we blur and the keyboard goes away, the whole menu disappears. I need to make the menu stay visible when the plus panel is open, even with keyboard dismissed.
Key changes:
isVisible should also be true when showPlusPanel is open+ is tapped: blur editor (keyboard goes), panel shows below the bareditor.chain().focus()...run() then blur again (or close panel and let keyboard come back)Now I see the issue — visibility is based on keyboardInfo.isVisible, which goes false when keyboard dismisses. The menu would disappear when we blur for the + panel. I need to make the menu stay visible when showPlusPanel is true, even if the keyboard is gone.
Key changes:
isVisible should be true if keyboard is showing OR plus panel is open+ is tapped: blur editor (keyboard goes away), show panel below the bareditor.chain().focus()...run() then blur again (or close panel and let keyboard come back)>npx tsc --noEmit 2>&1 | grep -i "BubbleMenu"No type errors. Here's what changed:
Layout: Primary bar on top, + panel opens below it (where the keyboard was).
Flow:
+ → editor.view.dom.blur() dismisses keyboard, panel slides in below the bar filling the keyboard's space (height matches last known keyboard height)editor.chain().focus() restores selection, runs command, then blur() again so keyboard doesn't flash back up+ again to close → panel closes, editor.commands.focus() brings keyboard backTipTap preserves the selection internally on blur, so .focus() restores cursor position before applying the command.
nah dislike this very much. too much flickering, and don't like the design.
Can we make the native menus like noteplan?
Let me check what menu/popover components you already have.
Found 9 files
src/App.tsx
src/components/Workspace/CreateForm.tsx
src/components/EditorActions.tsx
src/components/Common/OctarineMenu.tsx
src/components/NativeContextMenu/index.tsx
src/components/ContextMenu/index.tsx
src/utils/nativeContextMenu.ts
src/hooks/useContextMenu.ts
src/components/ContextMenu/Provider.tsx
[result-id: r18]Found 3 files
src/components/Common/OctarineDialog.tsx
src/components/ContextMenu/index.tsx
src/components/Common/Tooltip.tsxNow I see the pattern. You already have OctarineMenu (iOS action sheet style) and NativeContextMenu (uses Tauri invoke for actual native menus on iOS). The Noteplan approach would use these native-style menus triggered from toolbar buttons.
The plan:
[+] | B I Code Bullet Ordered Task [[ Attach | [⌨]+ opens an OctarineMenu (the iOS action sheet you already have) with items: Headings, Strikethrough, Highlight, Text Color, Code Block, Blockquote, Callout, Table, Linked Note, Indent/OutdentOctarineMenu for the sub-choicesThis is much simpler — no custom panel, no keyboard management. Just the compact bar + OctarineMenu popups.
The OctarineMenu expects items with { value, icon, onPress, label, destructive? } and manages its own open/close state. I can compose multiple menus — one for the main + list, and separate ones for sub-categories that open when a main item is tapped.
>npx tsc --noEmit 2>&1 | grep -i "BubbleMenu"Clean. Here's what we have now:
Primary bar (compact, no scroll needed): [+] | B I Code Bullet Ordered Task [[ Attach | [⌨]
+ button opens your existing OctarineMenu (iOS action sheet) with: Headings, Strikethrough, Highlight, Text Color, Code Block, Blockquote, Callout, Table, Linked Note, Indent/Outdent (contextual).
Items with sub-options (Headings, Highlight, Text Color, Callout, Table) open a second OctarineMenu for the sub-choices.
No keyboard dismissal, no blur/focus, no custom panels. Keyboard stays up the whole time. The menus overlay the content using the native-style action sheet you already have.
OctarineMenu isn't a native menu. I want a native one, like we have with select.
Found 6 files
src/components/Editor/BubbleMenu.tsx
src/components/Workspace/CreateForm.tsx
src-tauri/src/lib.rs
src-tauri/src/ios_context_menu.rs
src/components/NativeContextMenu/index.tsx
src/utils/nativeContextMenu.tsFound 3 files
src-tauri/src/lib.rs
src-tauri/src/ios_context_menu.rs
src/utils/nativeContextMenu.ts112- workspace::bootstrap_icloud_workspace,
113- workspace::get_workspace_stats,
114- workspace::inspect_icloud_container,
115- workspace::get_workspace_dir_mtime,
116- search::text_search,
117: ios_context_menu::show_native_context_menu,
118- set_swipe_back,
119- ])
120- .run(tauri::generate_context!())
121- .expect("error while running tauri application");
122-}
[result-id: r23]The native context menu isn't actually implemented on the Rust/iOS side — it just returns a string. Let me check how <select> works — you mentioned "like we have with select."
Found 6 files
src/components/Editor/extensions/codeblock/index.tsx
src/components/Workspace/CreateForm.tsx
src/pages/workspace/[uuid]/settings/date-time.tsx
src/pages/workspace/[uuid]/settings/appearance.tsx
src/pages/workspace/[uuid]/daily.tsx
src/components/Common/ThemeSelector.tsx
[result-id: r24]186- {/* Workspace Name + Color */}
187- <div className="space-y-2">
188- <label
189- htmlFor="workspace-name"
190- className="text-sm font-medium text-primary"
191- >
192- Workspace Name
193- </label>
194- <div className="flex items-center gap-3">
195- <div className="relative shrink-0">
196: <select
197- value={workspaceColor.background}
198- onChange={(e) => {
199- const selectedColor = rgbaColors.find(
200- (color) => color.background === e.target.value,
201- );
202- if (selectedColor) {
203- handleColorSelect(selectedColor);
204- }
205- }}
206- className="w-10 h-10 opacity-0 absolute inset-0 cursor-pointer"
[result-id: r25]So you're using a native <select> element — on iOS, this triggers the native picker wheel/popover. The trick is making the <select> invisible and positioning it over a styled button.
So the approach would be: for the + button and sub-menus, render invisible <select> elements that trigger native iOS pickers when tapped. The <select> sits over the + button, and the <option> values map to actions.
The limitation is <select> only supports flat text options (no icons, no nesting). On iOS it shows a native scroll wheel. For Noteplan-style dropdown menus, this won't look the same — but it will be native.
Is the native <select> wheel what you want, or were you thinking of something more like a native UIMenu (the popover-style dropdown that appears at the touch point)?
is there no native dropdown we can use? like the noteplan one?
>ls src-tauri/gen/apple/Sources/ 2>/dev/null; ls src-tauri/src/*.rs 2>/dev/null; ls src-tauri/gen/apple/ 2>/dev/null | head -20>find src-tauri/gen/apple/Sources -type f 2>/dev/null; find src-tauri/gen/apple/Octarine_iOS -type f 2>/dev/null | head -20>find src-tauri -name "*.swift" -type f 2>/dev/null; ls src-tauri/gen/apple/Externals/ 2>/dev/nullThere's no native Swift UIMenu implementation in the project. To get Noteplan-style native dropdown menus, we'd need to write a Swift plugin that presents a UIMenu or UIAlertController with action sheet style, and bridge it to the webview via Tauri commands.
The current ios_context_menu.rs is a stub — it doesn't actually show anything native.
Your realistic options:
Build a proper Tauri Swift plugin — implement UIMenu (popover at anchor point) or UIAlertController(.actionSheet) in Swift, call it from JS via invoke(). This gives you real native menus but requires Swift work + rebuilding the iOS app.
Use <select> with <optgroup> — native iOS picker wheel. Works today, no Swift needed, but it's a wheel picker not a dropdown.
Web-based menu styled to look native — like your OctarineMenu but more polished/popover-style. Not truly native but can look close.
Want me to build option 1 (the Swift UIMenu plugin)? That's the only way to get the actual native dropdown like Noteplan has.
im wondering. if the the + opens a @src/components/Common/Sheet.tsx would that work?
Yeah, that would work well actually. The Sheet (vaul drawer) slides up from the bottom, has a drag handle to dismiss, and it's already used in your callout type picker. It won't steal focus from the editor either — the keyboard stays up behind the overlay.
The flow would be:
+ → Sheet slides up over the keyboard with the grid of optionsClean, consistent with your existing UI patterns, no native bridging needed. Want me to implement it?
yes
>npx tsc --noEmit 2>&1 | grep -i "BubbleMenu"Clean. Here's the setup:
[+] | B I Code Bullet Ordered Task [[ Attach | [⌨]+ tap → opens a Sheet (vaul drawer) sliding up from the bottom with a 2-column grid of options> and swap the sheet content to the sub-view with a back buttonmodal={false} so the keyboard stays up behind itit hides behind the keyboard unfortunately.
That makes sense — the Sheet renders at bottom: 0 but the keyboard is covering that area. We need the Sheet to render above the keyboard, or use modal={true} which would likely dismiss the keyboard.
Let me check how the Sheet/Drawer handles positioning and if there's a way to offset it.
2:import { Drawer } from "vaul";
40: <Drawer.Root
49: {trigger && <Drawer.Trigger asChild>{trigger}</Drawer.Trigger>}
50: <Drawer.Portal>
51: <Drawer.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60]" />
52: <Drawer.Content
59: <Drawer.Handle className="mt-4 text-primary" />
64: <Drawer.Title className="text-base font-medium text-primary mb-1">
66: </Drawer.Title>
69: <Drawer.Description className="text-secondary">
71: </Drawer.Description>
77: </Drawer.Content>
78: </Drawer.Portal>
79: </Drawer.Root>
[result-id: r28]The Drawer.Content is fixed bottom-0 which sits behind the keyboard. Two options:
modal={true} (default) — this will dismiss the keyboard, the sheet takes over the screen. When sheet closes, we refocus the editor to bring keyboard back.container prop or style override to offset the bottom by keyboard height.Option 1 is simpler and actually matches the Noteplan behavior — when you tap their +, the keyboard goes away and the menu takes over. TipTap preserves the selection on blur, so when the sheet closes we just refocus.
Now it uses modal={true} (default). When the sheet opens, the keyboard dismisses and the sheet slides up from the actual bottom. When it closes (drag down, tap overlay, or pick an action), we refocus the editor so the keyboard comes back with the selection preserved.
keyboard still is visible, and i can see the sheet is BEHIND it.
The Sheet's z-index is 60/70 but the keyboard is a native OS element that sits above everything in the webview. We can't z-index above it.
We need to blur the editor before the sheet opens so the keyboard dismisses first.
But there's a problem — blurring the editor will cause isVisible to become false (keyboard gone), which hides the entire bubble menu before the sheet even opens. We need to keep the menu visible while the sheet is opening.
Now: tap + → blur editor (keyboard dismisses) → 100ms delay → sheet opens. Menu stays visible because showPlusSheet keeps isVisible true. When sheet closes, editor refocuses, keyboard comes back.
Try it now.
uninit var
591: const keyboardVisible = !isDisabled && editor.isEditable && keyboardInfo.isVisible;
593: const isVisible = keyboardVisible || showPlusSheet;The variables look fine. The issue is likely that showPlusSheet is declared after it's referenced. Let me check the order.
showPlusSheet is used on line 593 but declared on line 597. useState with const is hoisted in the TDZ so it's an error.
Fixed — moved showPlusSheet declaration before its usage.
works weird. the blurring causes the action to not work on selected text. and most actions like coloredText and stuff when selected on non-selected text, don't get applied going forward.
Also the closing of the sheet, doesn't auto focus the editor.
Hmm maybe we just do a long scrolling bubble menu, it is what it is.
Yeah, the blur/focus dance creates too many edge cases. Let's go back to basics — a single scrolling toolbar that just works. The original approach, cleaned up.
Reverted to the original scrolling bubble menu. Sometimes simple is best.
can the size of the icons be h-5 w-5? also how are we going to handle the multiple colors for highlight/coloredText?
For icon size, easy fix. For colors — we could add a long-press or double-tap on the highlight button to show color options. Or we could put small color dot indicators that scroll horizontally after the main highlight button.
But honestly, the simplest approach that fits the scrolling bar: tapping highlight/colored text cycles through or applies the default color, and the slash command (/) handles color selection when they want a specific one. The slash command already has full color pickers for both.
Or — we add the colors as additional buttons in the scroll. After the highlight brush icon, show 8 small color circles inline. Same for colored text. It makes the bar longer but it's already scrolling.
What's your preference?
lets try the tapping thing
So: tap highlight → applies default highlight. Tap again → cycles to next color. Same for colored text.
Let me update the icon size and add the cycling behavior.
49-
50- // If the highlight is already active with this exact color, toggle it off
51- if (editor.isActive("highlight", { color: normalizedColor })) {
52: return editor.chain().focus().unsetHighlight().run();
53- }
54-
55- // Always unset any existing highlight before applying new color
56- return editor
57- .chain()
58- .focus()
59: .unsetHighlight()
60- .setMark("highlight", { color: normalizedColor })
61- .run();
62- },
[result-id: r30]/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/highlight.tsx-49-
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/highlight.tsx-50- // If the highlight is already active with this exact color, toggle it off
src/components/Editor/extensions/highlight.tsx:51: if (editor.isActive("highlight", { color: normalizedColor })) {
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/highlight.tsx-52- return editor.chain().focus().unsetHighlight().run();
/Users/rajatkulkarni/Documents/projects/octarine-mobile/src/components/Editor/extensions/highlight.tsx-53- }
[result-id: r31]Good — I can use editor.getAttributes("highlight") to get the current color, and editor.isActive("highlight") to check if active. Same pattern for coloredText. Now let me implement the cycling.
Now let me update icon sizes to h-5 w-5, replace HighlightButton with a cycling version, and add a ColoredTextButton.
Now replace the HighlightButton with a cycling version and add ColoredTextButton:
Now add ColoredTextButton to the toolbar and make keyboard hide sticky right. Let me find the toolbar content section.