@cf/editor · beta
Editor
The chrome for a Tiptap editor, meaning a formatting menu over the selection and a slash menu on empty lines, with no fixed toolbar.
The editor here is plain Tiptap with StarterKit and task lists. Everything you see appear on top of it comes from this block.
This is the part of the notes editor on connorforsyth.co that isn't about notes. There's no toolbar. Select text and SelectionMenu floats over it with Bold, Italic, Code, Highlight, Heading, Quote and Link. Type / on an empty line and SlashMenu offers the block actions; keep typing to filter them, and use the arrows and Enter to pick one. Both work from one list of ToolbarActions, so an app adds its own (the notes editor adds images and note links) by passing a longer array.
The menus are labelled with words rather than icons, because nothing should need decoding. On a phone the selection menu goes below the selection instead of above it, since above is where iOS draws its own Copy and Look Up bar, and its buttons get taller. The row scrolls sideways instead of wrapping over what you're writing.
What isn't here is the editor itself: extensions, markdown in and out, autosave, uploads. Those differ per use, and on the site they're wired to notes. I've marked it beta because the actions assume StarterKit's command names and I haven't made that pluggable.
"use client";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { BLOCK_ACTIONS, SelectionMenu, SlashMenu } from "@/components/ui/editor";
const editor = useEditor({ extensions: [StarterKit, TaskList, TaskItem], immediatelyRender: false });
<EditorContent editor={editor} />
{editor && <SelectionMenu editor={editor} />}
{editor && <SlashMenu actions={BLOCK_ACTIONS} editor={editor} />}Props
| Prop | Type | Default |
|---|---|---|
| SelectionMenu editor | Editor | — |
| SlashMenu editor | Editor | — |
| SlashMenu actions | ToolbarAction[] | — |
| ActionButtons actions | ToolbarAction[] | — |
Rules
The same rules ship in DESIGN.md and the MCP server, for the agents building with this.
- must
Use these with an editor that has StarterKit and TaskList (
@tiptap/extension-list) loaded;TOOLBAR_ACTIONScall their commands.needs-starter-kit
- should
Put
SelectionMenuandSlashMenuon the editor instead of a fixed toolbar row; formatting shows up where you're already looking.no-fixed-toolbar
- should
Label a new
ToolbarActionwith a word (label: "Quote"), not a glyph, and give it atitlefor the accessible name.word-labels
- must
Give any custom menu button
onMouseDown={(event) => event.preventDefault()}, asActionButtonsdoes, so clicking it doesn't take the selection away.keep-selection
- may
Leave the Highlight action in
TOOLBAR_ACTIONSonly if the editor has a mark namedhighlight; otherwise filter it out.extra-marks-need-extensions
Install
bunx shadcn@latest add @cf/editorNeeds the @cf registry in your components.json once. The theme and any sibling components come along automatically.
Source
editor.tsxShowHide
"use client";
// Type-only: the actions below call StarterKit's and TaskList's commands, so
// the editor they're used with needs those extensions.
import type {} from "@tiptap/extension-list";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import type {} from "@tiptap/starter-kit";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "./button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./dialog";
import { Field, FieldLabel } from "./field";
import { Input } from "./input";
/**
* Chrome for a Tiptap editor: the actions, the menu that floats over a
* selection and the slash menu on an empty line. There is no fixed toolbar.
* The editor itself (extensions, content, saving) stays with whoever uses it:
* the notes editor and the system prompt editor on connorforsyth.co both do.
*/
export interface ToolbarAction {
isActive?: (editor: Editor) => boolean;
label: string;
run: (editor: Editor) => void;
title: string;
}
// Labelled with words, not glyphs, so nothing has to be decoded. Anything
// not here (subheadings, numbered lists) is still a markdown shortcut away.
export const TOOLBAR_ACTIONS: ToolbarAction[] = [
{
isActive: (editor) => editor.isActive("bold"),
label: "Bold",
run: (editor) => editor.chain().focus().toggleBold().run(),
title: "Bold",
},
{
isActive: (editor) => editor.isActive("italic"),
label: "Italic",
run: (editor) => editor.chain().focus().toggleItalic().run(),
title: "Italic",
},
{
isActive: (editor) => editor.isActive("code"),
label: "Code",
run: (editor) => editor.chain().focus().toggleCode().run(),
title: "Inline code",
},
{
isActive: (editor) => editor.isActive("highlight"),
label: "Highlight",
run: (editor) => editor.chain().focus().toggleMark("highlight").run(),
title: "Highlight",
},
{
isActive: (editor) => editor.isActive("heading", { level: 2 }),
label: "Heading",
run: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
title: "Heading",
},
{
isActive: (editor) => editor.isActive("bulletList"),
label: "List",
run: (editor) => editor.chain().focus().toggleBulletList().run(),
title: "Bullet list",
},
{
isActive: (editor) => editor.isActive("taskList"),
label: "Tasks",
run: (editor) => editor.chain().focus().toggleTaskList().run(),
title: "Task list",
},
{
isActive: (editor) => editor.isActive("blockquote"),
label: "Quote",
run: (editor) => editor.chain().focus().toggleBlockquote().run(),
title: "Quote",
},
{
isActive: (editor) => editor.isActive("codeBlock"),
label: "Code block",
run: (editor) => editor.chain().focus().toggleCodeBlock().run(),
title: "Code block",
},
];
// The editor has one typeface, one size and two tones: foreground for what
// you wrote, muted for everything that is a label or a control. Taller and
// wider on a phone, where these are the only way to format anything.
export const TOOL_CLASS =
"h-10 px-3 font-light text-base text-muted-foreground aria-pressed:bg-accent aria-pressed:text-foreground md:h-6 md:px-2 md:text-sm";
// The row holds seven or eight word-labelled buttons, which is wider than a
// phone. Rather than wrap it into a block that covers what you're writing, it
// scrolls sideways — and keeps that scroll to itself, so swiping it doesn't
// trigger the browser's back gesture.
export const MENU_CLASS =
"flex max-w-[calc(100vw-1.5rem)] items-center gap-0.5 overflow-x-auto overscroll-x-contain rounded-lg border border-border bg-popover p-1 shadow-md";
// Marks only make sense on a selection; the rest restructure a whole block.
const INLINE_TITLES = new Set(["Bold", "Italic", "Inline code", "Highlight"]);
const SELECTION_TITLES = new Set([...INLINE_TITLES, "Heading", "Quote"]);
// Over a selection: what you do to words. On an empty line: what you start.
export const SELECTION_ACTIONS = TOOLBAR_ACTIONS.filter((action) =>
SELECTION_TITLES.has(action.title)
);
export const BLOCK_ACTIONS = TOOLBAR_ACTIONS.filter(
(action) => !INLINE_TITLES.has(action.title)
);
export const ActionButtons = ({
actions,
editor,
}: {
actions: ToolbarAction[];
editor: Editor;
}) =>
actions.map((action) => (
<Button
aria-label={action.title}
aria-pressed={action.isActive?.(editor) ?? false}
className={TOOL_CLASS}
key={action.title}
onClick={() => action.run(editor)}
// Keeps the selection: a mousedown would otherwise move focus to the
// button and the menu would close before the click landed.
onMouseDown={(event) => event.preventDefault()}
size="xs"
title={action.title}
type="button"
variant="ghost"
>
{action.label}
</Button>
));
/**
* Whether the thing pointing at the screen is a finger rather than a mouse.
* Only for what CSS can't decide: where a floating menu goes so it doesn't
* land under iOS's own selection bar. Starts false so the server and the
* first client render agree; a touch device corrects it on mount.
*/
const useCoarsePointer = (): boolean => {
const [coarse, setCoarse] = useState(false);
useEffect(() => {
const query = window.matchMedia("(pointer: coarse)");
const sync = () => setCoarse(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return coarse;
};
/** Appears over whatever text is selected. There is no fixed toolbar. */
export const SelectionMenu = ({ editor }: { editor: Editor }) => {
const coarse = useCoarsePointer();
// null while closed; the link already on the selection, or "", while open.
const [href, setHref] = useState<string | null>(null);
const applyLink = (value: string) => {
setHref(null);
const chain = editor.chain().focus().extendMarkRange("link");
if (value.trim() === "") {
chain.unsetLink().run();
return;
}
chain.setLink({ href: value.trim() }).run();
};
return (
<>
<BubbleMenu
className={MENU_CLASS}
editor={editor}
// The pointer type is only known once mounted, and the menu reads its
// options when it is created: remounting is what makes the placement
// below actually take.
key={coarse ? "coarse" : "fine"}
// Above the selection on a pointer. On a touch screen that is exactly
// where iOS draws its own Copy / Look Up bar, so it goes below.
options={{ offset: 8, placement: coarse ? "bottom" : "top" }}
// Text only: an image or a [[link]] chip has nothing to make bold.
shouldShow={({ editor: current, state }) =>
!state.selection.empty &&
state.selection instanceof TextSelection &&
!current.isActive("codeBlock")
}
>
<ActionButtons actions={SELECTION_ACTIONS} editor={editor} />
<Button
aria-pressed={editor.isActive("link")}
className={TOOL_CLASS}
onClick={() =>
setHref(
(editor.getAttributes("link").href as string | undefined) ?? ""
)
}
onMouseDown={(event) => event.preventDefault()}
size="xs"
type="button"
variant="ghost"
>
Link
</Button>
</BubbleMenu>
{href !== null && (
<LinkDialog
href={href}
onCancel={() => setHref(null)}
onSubmit={applyLink}
/>
)}
</>
);
};
/**
* One field, the way `window.prompt` was — but a prompt blocks the page, and
* on a phone it takes the keyboard and the selection with it when it goes.
* Mounted only while open, so it always starts from the current link.
*/
const LinkDialog = ({
href,
onCancel,
onSubmit,
}: {
href: string;
onCancel: () => void;
onSubmit: (value: string) => void;
}) => {
const [value, setValue] = useState(href);
return (
<Dialog onOpenChange={(open) => !open && onCancel()} open>
<DialogContent data-editor-dialog>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit(value);
}}
>
<DialogHeader>
<DialogTitle>Link</DialogTitle>
<DialogDescription>
Where the selected words should go. Clear the field to take the
link off again.
</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="link-href">Address</FieldLabel>
<Input
autoCapitalize="none"
autoCorrect="off"
id="link-href"
inputMode="url"
onChange={(event) => setValue(event.target.value)}
placeholder="https://…"
spellCheck={false}
value={value}
/>
</Field>
<DialogFooter>
<Button onClick={onCancel} type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
const slashMenuKey = new PluginKey("slashMenu");
const queryPattern = /^\/([^\s/]*)$/;
const nextIndex = (index: number, count: number, direction: string) =>
count ? (index + (direction === "ArrowDown" ? 1 : -1) + count) % count : 0;
interface MenuState {
actions: ToolbarAction[];
from: number;
index: number;
left: number;
to: number;
top: number;
}
/**
* Type "/" at the start of an empty line to pick one of `actions`; keep
* typing to filter, arrows and Enter to choose, Escape to dismiss for that
* line.
*/
export function SlashMenu({
actions,
editor,
}: {
actions: ToolbarAction[];
editor: Editor;
}) {
const [menu, setMenu] = useState<MenuState | null>(null);
useEffect(() => {
let current: MenuState | null = null;
let dismissed: number | null = null;
const close = () => {
current = null;
setMenu(null);
};
const update = () => {
const { selection } = editor.state;
const { $from } = selection;
const match = queryPattern.exec($from.parent.textContent);
if (
!(editor.isFocused && selection.empty && match) ||
editor.isActive("codeBlock")
) {
dismissed = null;
close();
return;
}
const from = $from.start();
if (
dismissed === from ||
$from.parentOffset !== $from.parent.content.size
) {
close();
return;
}
const query = match[1].toLowerCase();
const filtered = actions.filter((action) =>
`${action.label} ${action.title}`.toLowerCase().includes(query)
);
const rect = editor.view.coordsAtPos(from);
current = {
actions: filtered,
from,
index: current?.to === selection.from ? current.index : 0,
left: Math.max(8, Math.min(rect.left, window.innerWidth - 248)),
to: selection.from,
top: Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - 280)),
};
setMenu(current);
};
editor.registerPlugin(
new Plugin({
key: slashMenuKey,
props: {
handleKeyDown: (_view, event) => {
if (!current || event.isComposing) {
return false;
}
if (event.key === "Escape") {
dismissed = current.from;
close();
return true;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const count = current.actions.length;
current = {
...current,
index: nextIndex(current.index, count, event.key),
};
setMenu(current);
return true;
}
if (event.key === "Enter" && current.actions[current.index]) {
const action = current.actions[current.index];
editor.commands.deleteRange({
from: current.from,
to: current.to,
});
close();
action.run(editor);
return true;
}
return false;
},
},
view: () => ({ update }),
}),
(plugin, plugins) => [plugin, ...plugins]
);
editor.on("focus", update);
editor.on("blur", close);
window.addEventListener("resize", update);
window.addEventListener("scroll", update, true);
return () => {
editor.unregisterPlugin(slashMenuKey);
editor.off("focus", update);
editor.off("blur", close);
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
};
}, [actions, editor]);
if (!menu) {
return null;
}
return createPortal(
<div
aria-label="Insert block"
className="fixed z-50 flex max-h-64 w-60 flex-col overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-md"
role="menu"
style={{ left: menu.left, top: menu.top }}
>
{menu.actions.length === 0 && (
<p className="px-3 py-2 text-muted-foreground text-sm">
No matching blocks
</p>
)}
{menu.actions.map((action, index) => (
<button
className="rounded-md px-3 py-2 text-left text-sm hover:bg-accent data-[selected=true]:bg-accent"
data-selected={index === menu.index}
key={action.title}
onClick={() => {
editor.commands.deleteRange({ from: menu.from, to: menu.to });
setMenu(null);
action.run(editor);
}}
onMouseDown={(event) => event.preventDefault()}
role="menuitem"
type="button"
>
{action.label}
</button>
))}
</div>,
document.body
);
}