From c81769b92692c4a31a14ceb202c9c42a5f2c802f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 24 Mar 2026 12:29:50 +0400 Subject: [PATCH] Use slash to display menu and filter commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- packages/ui/src/RichEditor/BlockMenu.tsx | 348 +++++++++++------- packages/ui/src/RichEditor/BubbleMenu.tsx | 4 + packages/ui/src/RichEditor/LinkExtension.ts | 4 + packages/ui/src/RichEditor/MenuButton.tsx | 4 + packages/ui/src/RichEditor/OptionsMenu.tsx | 4 + .../ui/src/RichEditor/PlaceholderExtension.ts | 43 +++ packages/ui/src/RichEditor/RichEditor.tsx | 8 + .../src/RichEditor/SlashCommandExtension.ts | 145 ++++++++ packages/ui/src/rich-editor.css | 14 + 9 files changed, 449 insertions(+), 125 deletions(-) create mode 100644 packages/ui/src/RichEditor/PlaceholderExtension.ts create mode 100644 packages/ui/src/RichEditor/SlashCommandExtension.ts diff --git a/packages/ui/src/RichEditor/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu.tsx index e2c907f0e..e127f2af5 100644 --- a/packages/ui/src/RichEditor/BlockMenu.tsx +++ b/packages/ui/src/RichEditor/BlockMenu.tsx @@ -1,20 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// 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>["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>): SlashCommandStorage | undefined { + return (editor.storage as unknown as Record).slashCommand as + | SlashCommandStorage + | undefined; +} + type BlockMenuProps = { editor: ReturnType; }; export function BlockMenu({ editor }: BlockMenuProps) { - const [menuOpen, setMenuOpen] = useState(false); - const [triggerEl, setTriggerEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(null); const [hoveredBlock, setHoveredBlock] = useState(null); + const [slashNav, setSlashNav] = useState({ index: 0, query: "" }); const rafId = useRef(null); + const slashDropdownRef = useRef(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) => ReturnType) => { + 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 ( <> - - {menuOpen && ( + {hoveredBlock != null && ( + + )} + {slashState.active && (
{ - setDropdownEl(node); - menuRefs.setFloating(node); + slashDropdownRef.current = node; + slashMenuRefs.setFloating(node); }} - style={menuStyles} - {...getFloatingProps()} + style={slashMenuStyles} onMouseDown={e => e.preventDefault()} className={menu()} > -
Style
- handleAction(chain => chain.setParagraph())} - > - - Text - - handleAction(chain => chain.toggleHeading({ level: 1 }))} - > - - Heading 1 - - handleAction(chain => chain.toggleHeading({ level: 2 }))} - > - - Heading 2 - - handleAction(chain => chain.toggleHeading({ level: 3 }))} - > - - Heading 3 - - handleAction(chain => chain.toggleBulletList())} - > - - Bullet List - - handleAction(chain => chain.toggleOrderedList())} - > - - Ordered List - - handleAction(chain => chain.toggleCodeBlock())} - > - - Code Block - - handleAction(chain => chain.toggleBlockquote())} - > - - Blockquote - - handleAction(chain => chain.setHorizontalRule())} - > - - Divider - + {filteredItems.length > 0 + ? filteredItems.map((item, index) => ( + handleSlashAction(item)} + > + + {item.label} + + )) + : ( +
+ No results +
+ )}
)} diff --git a/packages/ui/src/RichEditor/BubbleMenu.tsx b/packages/ui/src/RichEditor/BubbleMenu.tsx index 07f2152c4..f2da9f856 100644 --- a/packages/ui/src/RichEditor/BubbleMenu.tsx +++ b/packages/ui/src/RichEditor/BubbleMenu.tsx @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Probo Inc . +// 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"; diff --git a/packages/ui/src/RichEditor/LinkExtension.ts b/packages/ui/src/RichEditor/LinkExtension.ts index c884991b3..4246e4659 100644 --- a/packages/ui/src/RichEditor/LinkExtension.ts +++ b/packages/ui/src/RichEditor/LinkExtension.ts @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Probo Inc . +// 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"; diff --git a/packages/ui/src/RichEditor/MenuButton.tsx b/packages/ui/src/RichEditor/MenuButton.tsx index ed9d07519..e61700461 100644 --- a/packages/ui/src/RichEditor/MenuButton.tsx +++ b/packages/ui/src/RichEditor/MenuButton.tsx @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Probo Inc . +// 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"; diff --git a/packages/ui/src/RichEditor/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu.tsx index c27d71017..afb62c0db 100644 --- a/packages/ui/src/RichEditor/OptionsMenu.tsx +++ b/packages/ui/src/RichEditor/OptionsMenu.tsx @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + import { autoUpdate, flip, diff --git a/packages/ui/src/RichEditor/PlaceholderExtension.ts b/packages/ui/src/RichEditor/PlaceholderExtension.ts new file mode 100644 index 000000000..3f5b3d4cb --- /dev/null +++ b/packages/ui/src/RichEditor/PlaceholderExtension.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Probo Inc . +// 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", + }), + ]); + }, + }, + }), + ]; + }, +}); diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 9d200adae..cf016a9b4 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -1,3 +1,7 @@ +// Copyright (c) 2026 Probo Inc . +// 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, diff --git a/packages/ui/src/RichEditor/SlashCommandExtension.ts b/packages/ui/src/RichEditor/SlashCommandExtension.ts new file mode 100644 index 000000000..24f90bb80 --- /dev/null +++ b/packages/ui/src/RichEditor/SlashCommandExtension.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Probo Inc . +// 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({ + 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), + ]); + } catch { + deactivateSlashCommand(storage); + return DecorationSet.empty; + } + }, + }, + }), + ]; + }, +}); diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index fc4e93135..1d2c685ac 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -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; + } }