Use slash to display menu and filter commands

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-24 12:29:50 +04:00
parent 9c810eb1dc
commit c81769b926
9 changed files with 449 additions and 125 deletions

View File

@@ -1,20 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import type { Icon } from "@phosphor-icons/react";
import { CodeBlockIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
import { type useEditor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { type useEditor, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { MenuButton } from "./MenuButton";
import type { SlashCommandStorage } from "./SlashCommandExtension";
import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension";
type ChainCommands = ReturnType<NonNullable<ReturnType<typeof useEditor>>["chain"]>;
type BlockItem = {
label: string;
icon: Icon;
action: (chain: ChainCommands) => ChainCommands;
};
const BLOCK_ITEMS: BlockItem[] = [
{ label: "Text", icon: TextTIcon, action: chain => chain.setParagraph() },
{ label: "Heading 1", icon: TextHOneIcon, action: chain => chain.toggleHeading({ level: 1 }) },
{ label: "Heading 2", icon: TextHTwoIcon, action: chain => chain.toggleHeading({ level: 2 }) },
{ label: "Heading 3", icon: TextHThreeIcon, action: chain => chain.toggleHeading({ level: 3 }) },
{ label: "Bullet List", icon: ListBulletsIcon, action: chain => chain.toggleBulletList() },
{ label: "Ordered List", icon: ListNumbersIcon, action: chain => chain.toggleOrderedList() },
{ label: "Code Block", icon: CodeBlockIcon, action: chain => chain.toggleCodeBlock() },
{ label: "Blockquote", icon: QuotesIcon, action: chain => chain.toggleBlockquote() },
{ label: "Divider", icon: MinusIcon, action: chain => chain.setHorizontalRule() },
];
const blockMenuVariants = tv({
slots: {
@@ -38,16 +61,142 @@ function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
function getSlashStorage(editor: NonNullable<ReturnType<typeof useEditor>>): SlashCommandStorage | undefined {
return (editor.storage as unknown as Record<string, unknown>).slashCommand as
| SlashCommandStorage
| undefined;
}
type BlockMenuProps = {
editor: ReturnType<typeof useEditor>;
};
export function BlockMenu({ editor }: BlockMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
const [slashNav, setSlashNav] = useState({ index: 0, query: "" });
const rafId = useRef<number | null>(null);
const slashDropdownRef = useRef<HTMLDivElement | null>(null);
const slashState = useEditorState({
editor,
selector: ({ editor: e }) => {
const s = getSlashStorage(e);
return {
active: s?.active ?? false,
query: s?.query ?? "",
from: s?.from ?? 0,
};
},
});
const slashActiveIndex = slashState.query === slashNav.query
? slashNav.index
: 0;
const filteredItems = useMemo(() => {
if (!slashState.active) return BLOCK_ITEMS;
const q = slashState.query.toLowerCase();
if (q.length === 0) return BLOCK_ITEMS;
return BLOCK_ITEMS.filter(item => item.label.toLowerCase().includes(q));
}, [slashState.active, slashState.query]);
const {
refs: slashMenuRefs,
floatingStyles: slashMenuStyles,
} = useFloating({
strategy: "fixed",
placement: "bottom-start",
middleware: [offset(4), flip(), shift()],
whileElementsMounted: autoUpdate,
});
useLayoutEffect(() => {
if (!slashState.active || !editor) {
slashMenuRefs.setPositionReference(null);
return;
}
const coords = editor.view.coordsAtPos(slashState.from);
slashMenuRefs.setPositionReference({
getBoundingClientRect: () => ({
x: coords.left,
y: coords.top,
top: coords.top,
left: coords.left,
bottom: coords.bottom,
right: coords.left,
width: 0,
height: coords.bottom - coords.top,
}),
});
}, [slashState.active, slashState.from, editor, slashMenuRefs]);
const deactivateSlash = useCallback(() => {
if (!editor) return;
const s = getSlashStorage(editor);
if (s) deactivateSlashCommand(s);
setSlashNav({ index: 0, query: "" });
}, [editor]);
const handleSlashAction = useCallback(
(item: BlockItem) => {
if (!editor || !slashState.active) return;
const { from } = slashState;
const cursorPos = editor.state.selection.from;
try {
editor.chain()
.focus()
.deleteRange({ from, to: cursorPos })
.run();
item.action(editor.chain().focus()).run();
} catch {
// Block may no longer be in the document
}
deactivateSlash();
},
[editor, slashState, deactivateSlash],
);
useEffect(() => {
if (!editor || editor.isDestroyed || !slashState.active) return;
const editorDom = editor.view.dom;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopImmediatePropagation();
setSlashNav(prev => ({
query: slashState.query,
index: (prev.query === slashState.query ? prev.index : 0) < filteredItems.length - 1
? (prev.query === slashState.query ? prev.index : 0) + 1
: 0,
}));
} else if (e.key === "ArrowUp") {
e.preventDefault();
e.stopImmediatePropagation();
setSlashNav(prev => ({
query: slashState.query,
index: (prev.query === slashState.query ? prev.index : 0) > 0
? (prev.query === slashState.query ? prev.index : 0) - 1
: filteredItems.length - 1,
}));
} else if (e.key === "Enter") {
e.preventDefault();
e.stopImmediatePropagation();
const item = filteredItems[slashActiveIndex];
if (item) {
handleSlashAction(item);
}
}
};
editorDom.addEventListener("keydown", onKeyDown, { capture: true });
return () => {
editorDom.removeEventListener("keydown", onKeyDown, { capture: true });
};
}, [editor, slashState.active, slashState.query, filteredItems, slashActiveIndex, handleSlashAction]);
const {
refs: triggerRefs,
@@ -60,30 +209,12 @@ export function BlockMenu({ editor }: BlockMenuProps) {
whileElementsMounted: autoUpdate,
});
const menuRootContext = useFloatingRootContext({
open: menuOpen,
onOpenChange: setMenuOpen,
elements: { reference: triggerEl, floating: dropdownEl },
});
const { refs: menuRefs, floatingStyles: menuStyles } = useFloating({
rootContext: menuRootContext,
strategy: "fixed",
placement: "bottom-start",
middleware: [offset(4), flip(), shift()],
whileElementsMounted: autoUpdate,
});
const click = useClick(menuRootContext);
const dismiss = useDismiss(menuRootContext);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const editorDom = editor.view.dom;
const onMouseMove = (e: MouseEvent) => {
if (menuOpen) return;
if (slashState.active) return;
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
@@ -118,140 +249,107 @@ export function BlockMenu({ editor }: BlockMenuProps) {
rafId.current = null;
}
};
}, [editor, menuOpen]);
}, [editor, slashState.active]);
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
const shouldShow = hoveredBlock != null || menuOpen;
if (!editor) return null;
if (!editor || !shouldShow) return null;
const handleAction = (applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>) => {
const handleTriggerClick = () => {
if (!hoveredBlock) return;
try {
const pos = editor.view.posAtDOM(hoveredBlock, 0);
const $pos = editor.state.doc.resolve(pos);
const rootPos = $pos.depth >= 1 ? $pos.before(1) : pos;
const rootNode = $pos.depth >= 1 ? $pos.node(1) : $pos.nodeAfter;
if (rootNode && rootNode.isTextblock && rootNode.content.size === 0) {
const textPos = rootPos + 1;
editor.chain()
.focus()
.setTextSelection(textPos)
.insertContent("/")
.run();
const s = getSlashStorage(editor);
if (s) activateSlashCommand(s, textPos);
return;
}
let insertPos: number;
if ($pos.depth >= 1) {
const rootPos = $pos.before(1);
const rootNode = $pos.node(1);
insertPos = rootPos + rootNode.nodeSize;
insertPos = rootPos + rootNode!.nodeSize;
} else {
const nodeAfter = $pos.nodeAfter;
insertPos = pos + (nodeAfter?.nodeSize ?? 1);
}
const textPos = insertPos + 1;
editor.chain()
.focus()
.insertContentAt(insertPos, { type: "paragraph" })
.setTextSelection(insertPos + 1)
.setTextSelection(textPos)
.insertContent("/")
.run();
applyCommand(editor.chain()).run();
const s = getSlashStorage(editor);
if (s) activateSlashCommand(s, textPos);
} catch {
// Block may no longer be in the document
}
setMenuOpen(false);
};
return (
<>
<button
ref={(node) => {
triggerRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
{...getReferenceProps()}
onMouseDown={e => e.preventDefault()}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<PlusIcon size={16} weight="bold" />
</button>
{menuOpen && (
{hoveredBlock != null && (
<button
ref={(node) => {
triggerRefs.setFloating(node);
}}
onClick={handleTriggerClick}
onMouseDown={e => e.preventDefault()}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<PlusIcon size={16} weight="bold" />
</button>
)}
{slashState.active && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
slashDropdownRef.current = node;
slashMenuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
style={slashMenuStyles}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
<div className="p-1 font-semibold text-sm">Style</div>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
>
<TextHOneIcon size={16} weight="bold" />
Heading 1
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
>
<TextHTwoIcon size={16} weight="bold" />
Heading 2
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
>
<TextHThreeIcon size={16} weight="bold" />
Heading 3
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleBulletList())}
>
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
>
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
>
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton
active={false}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
>
<QuotesIcon size={16} weight="bold" />
Blockquote
</MenuButton>
<MenuButton
onClick={() => handleAction(chain => chain.setHorizontalRule())}
>
<MinusIcon size={16} weight="bold" />
Divider
</MenuButton>
{filteredItems.length > 0
? filteredItems.map((item, index) => (
<MenuButton
key={item.label}
active={index === slashActiveIndex}
onClick={() => handleSlashAction(item)}
>
<item.icon size={16} weight="bold" />
{item.label}
</MenuButton>
))
: (
<div className="px-2 py-1.5 text-sm text-txt-tertiary">
No results
</div>
)}
</div>
)}
</>

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { CodeIcon, LinkIcon, TextBIcon, TextItalicIcon, TextStrikethroughIcon, TextUnderlineIcon, TrashIcon } from "@phosphor-icons/react";
import { Editor, useEditorState } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { Link } from "@tiptap/extension-link";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { getAttributes } from "@tiptap/react";

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import type { PropsWithChildren } from "react";
import { tv } from "tailwind-variants";

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
autoUpdate,
flip,

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const placeholderKey = new PluginKey("placeholder");
export const PlaceholderExtension = Extension.create({
name: "placeholder",
addProseMirrorPlugins() {
return [
new Plugin({
key: placeholderKey,
props: {
decorations(state) {
const { selection } = state;
if (!selection.empty) return DecorationSet.empty;
const $pos = selection.$from;
const node = $pos.parent;
if (node.type.name !== "paragraph") return DecorationSet.empty;
if (node.content.size !== 0) return DecorationSet.empty;
const pos = $pos.before($pos.depth);
return DecorationSet.create(state.doc, [
Decoration.node(pos, pos + node.nodeSize, {
"class": "is-empty-focused",
"data-placeholder": "Write or type / for commands\u2026",
}),
]);
},
},
}),
];
},
});

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { Blockquote } from "@tiptap/extension-blockquote";
import { Bold } from "@tiptap/extension-bold";
import { Code } from "@tiptap/extension-code";
@@ -21,6 +25,8 @@ import { BlockMenu } from "./BlockMenu";
import { BubbleMenu } from "./BubbleMenu";
import { LinkExtension } from "./LinkExtension";
import { OptionsMenu } from "./OptionsMenu";
import { PlaceholderExtension } from "./PlaceholderExtension";
import { SlashCommandExtension } from "./SlashCommandExtension";
const extensions = [
Document,
@@ -36,6 +42,8 @@ const extensions = [
Code,
CodeBlock,
LinkExtension,
SlashCommandExtension,
PlaceholderExtension,
Blockquote,
BulletList,
OrderedList,

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
const slashCommandKey = new PluginKey("slashCommand");
export type SlashCommandStorage = {
active: boolean;
query: string;
from: number;
};
export function activateSlashCommand(storage: SlashCommandStorage, from: number) {
storage.active = true;
storage.query = "";
storage.from = from;
}
export function deactivateSlashCommand(storage: SlashCommandStorage) {
storage.active = false;
storage.query = "";
storage.from = 0;
}
export const SlashCommandExtension = Extension.create<object, SlashCommandStorage>({
name: "slashCommand",
addStorage() {
return {
active: false,
query: "",
from: 0,
};
},
addProseMirrorPlugins() {
const storage = this.storage;
return [
new Plugin({
key: slashCommandKey,
props: {
handleTextInput(view, from, _to, text) {
if (text !== "/") return false;
if (storage.active) return false;
const { state } = view;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
if ($from.marks().some(m => m.type.name === "code")) return false;
const blockStart = $from.start($from.depth);
if (from !== blockStart) return false;
if ($from.parent.textContent.length !== 0) return false;
storage.active = true;
storage.from = from;
storage.query = "";
return false;
},
handleKeyDown(view, event) {
if (!storage.active) return false;
if (event.key === "Escape") {
const { state } = view;
const cursorPos = state.selection.from;
const from = storage.from;
deactivateSlashCommand(storage);
if (cursorPos > from) {
const tr = state.tr.delete(from, cursorPos);
view.dispatch(tr);
}
return true;
}
if (event.key === "Backspace") {
const { state } = view;
const cursorPos = state.selection.from;
if (cursorPos <= storage.from + 1) {
deactivateSlashCommand(storage);
}
}
return false;
},
decorations(state) {
if (!storage.active) return DecorationSet.empty;
const { from } = storage;
const cursorPos = state.selection.from;
try {
const $from = state.doc.resolve(from);
const blockStart = $from.start($from.depth);
const blockEnd = $from.end($from.depth);
if (cursorPos < blockStart || cursorPos > blockEnd) {
deactivateSlashCommand(storage);
return DecorationSet.empty;
}
const text = state.doc.textBetween(from, cursorPos);
if (!text.startsWith("/")) {
deactivateSlashCommand(storage);
return DecorationSet.empty;
}
storage.query = text.slice(1);
const decoEnd = Math.max(cursorPos, from + 1);
const isEmpty = storage.query.length === 0;
return DecorationSet.create(state.doc, [
Decoration.inline(from, decoEnd, {
"nodeName": "span",
"class": "slash-search",
"data-placeholder": "Search",
"data-empty": isEmpty
? "true"
: "false",
} as Record<string, string>),
]);
} catch {
deactivateSlashCommand(storage);
return DecorationSet.empty;
}
},
},
}),
];
},
});

View File

@@ -52,4 +52,18 @@
pre { @apply my-4 px-6 py-4 bg-border-solid rounded-lg text-sm; }
code { @apply text-inherit font-mono bg-border-solid rounded-md; }
p.is-empty-focused::before {
content: attr(data-placeholder);
@apply text-txt-tertiary pointer-events-none float-left h-0;
}
.slash-search {
@apply bg-subtle rounded px-1.5 py-0.5;
}
.slash-search[data-empty="true"]::after {
content: attr(data-placeholder);
@apply text-txt-tertiary;
}
}