Use slash to display menu and filter commands
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -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 {
|
import {
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
flip,
|
flip,
|
||||||
offset,
|
offset,
|
||||||
shift,
|
shift,
|
||||||
useClick,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
useFloating,
|
||||||
useFloatingRootContext,
|
|
||||||
useInteractions,
|
|
||||||
} from "@floating-ui/react";
|
} 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 { CodeBlockIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
|
||||||
import { type useEditor } from "@tiptap/react";
|
import { type useEditor, useEditorState } from "@tiptap/react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
import { MenuButton } from "./MenuButton";
|
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({
|
const blockMenuVariants = tv({
|
||||||
slots: {
|
slots: {
|
||||||
@@ -38,16 +61,142 @@ function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement
|
|||||||
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
|
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 = {
|
type BlockMenuProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: ReturnType<typeof useEditor>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BlockMenu({ editor }: BlockMenuProps) {
|
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 [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
|
||||||
|
const [slashNav, setSlashNav] = useState({ index: 0, query: "" });
|
||||||
const rafId = useRef<number | null>(null);
|
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 {
|
const {
|
||||||
refs: triggerRefs,
|
refs: triggerRefs,
|
||||||
@@ -60,30 +209,12 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
whileElementsMounted: autoUpdate,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!editor || editor.isDestroyed) return;
|
if (!editor || editor.isDestroyed) return;
|
||||||
const editorDom = editor.view.dom;
|
const editorDom = editor.view.dom;
|
||||||
|
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
if (menuOpen) return;
|
if (slashState.active) return;
|
||||||
|
|
||||||
if (rafId.current) return;
|
if (rafId.current) return;
|
||||||
rafId.current = requestAnimationFrame(() => {
|
rafId.current = requestAnimationFrame(() => {
|
||||||
@@ -118,56 +249,70 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
rafId.current = null;
|
rafId.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [editor, menuOpen]);
|
}, [editor, slashState.active]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
triggerRefs.setReference(hoveredBlock);
|
triggerRefs.setReference(hoveredBlock);
|
||||||
}, [hoveredBlock, triggerRefs]);
|
}, [hoveredBlock, triggerRefs]);
|
||||||
|
|
||||||
const shouldShow = hoveredBlock != null || menuOpen;
|
if (!editor) return null;
|
||||||
|
|
||||||
if (!editor || !shouldShow) return null;
|
const handleTriggerClick = () => {
|
||||||
|
|
||||||
const handleAction = (applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>) => {
|
|
||||||
if (!hoveredBlock) return;
|
if (!hoveredBlock) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pos = editor.view.posAtDOM(hoveredBlock, 0);
|
const pos = editor.view.posAtDOM(hoveredBlock, 0);
|
||||||
const $pos = editor.state.doc.resolve(pos);
|
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;
|
let insertPos: number;
|
||||||
if ($pos.depth >= 1) {
|
if ($pos.depth >= 1) {
|
||||||
const rootPos = $pos.before(1);
|
insertPos = rootPos + rootNode!.nodeSize;
|
||||||
const rootNode = $pos.node(1);
|
|
||||||
insertPos = rootPos + rootNode.nodeSize;
|
|
||||||
} else {
|
} else {
|
||||||
const nodeAfter = $pos.nodeAfter;
|
const nodeAfter = $pos.nodeAfter;
|
||||||
insertPos = pos + (nodeAfter?.nodeSize ?? 1);
|
insertPos = pos + (nodeAfter?.nodeSize ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textPos = insertPos + 1;
|
||||||
|
|
||||||
editor.chain()
|
editor.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContentAt(insertPos, { type: "paragraph" })
|
.insertContentAt(insertPos, { type: "paragraph" })
|
||||||
.setTextSelection(insertPos + 1)
|
.setTextSelection(textPos)
|
||||||
|
.insertContent("/")
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
applyCommand(editor.chain()).run();
|
const s = getSlashStorage(editor);
|
||||||
|
if (s) activateSlashCommand(s, textPos);
|
||||||
} catch {
|
} catch {
|
||||||
// Block may no longer be in the document
|
// Block may no longer be in the document
|
||||||
}
|
}
|
||||||
|
|
||||||
setMenuOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{hoveredBlock != null && (
|
||||||
<button
|
<button
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
triggerRefs.setFloating(node);
|
triggerRefs.setFloating(node);
|
||||||
setTriggerEl(node);
|
|
||||||
menuRefs.setReference(node);
|
|
||||||
}}
|
}}
|
||||||
{...getReferenceProps()}
|
onClick={handleTriggerClick}
|
||||||
onMouseDown={e => e.preventDefault()}
|
onMouseDown={e => e.preventDefault()}
|
||||||
type="button"
|
type="button"
|
||||||
style={{
|
style={{
|
||||||
@@ -178,80 +323,33 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
>
|
>
|
||||||
<PlusIcon size={16} weight="bold" />
|
<PlusIcon size={16} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
)}
|
||||||
|
{slashState.active && (
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
setDropdownEl(node);
|
slashDropdownRef.current = node;
|
||||||
menuRefs.setFloating(node);
|
slashMenuRefs.setFloating(node);
|
||||||
}}
|
}}
|
||||||
style={menuStyles}
|
style={slashMenuStyles}
|
||||||
{...getFloatingProps()}
|
|
||||||
onMouseDown={e => e.preventDefault()}
|
onMouseDown={e => e.preventDefault()}
|
||||||
className={menu()}
|
className={menu()}
|
||||||
>
|
>
|
||||||
<div className="p-1 font-semibold text-sm">Style</div>
|
{filteredItems.length > 0
|
||||||
|
? filteredItems.map((item, index) => (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={false}
|
key={item.label}
|
||||||
onClick={() => handleAction(chain => chain.setParagraph())}
|
active={index === slashActiveIndex}
|
||||||
|
onClick={() => handleSlashAction(item)}
|
||||||
>
|
>
|
||||||
<TextTIcon size={16} weight="bold" />
|
<item.icon size={16} weight="bold" />
|
||||||
Text
|
{item.label}
|
||||||
</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>
|
</MenuButton>
|
||||||
|
))
|
||||||
|
: (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-txt-tertiary">
|
||||||
|
No results
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 { CodeIcon, LinkIcon, TextBIcon, TextItalicIcon, TextStrikethroughIcon, TextUnderlineIcon, TrashIcon } from "@phosphor-icons/react";
|
||||||
import { Editor, useEditorState } from "@tiptap/react";
|
import { Editor, useEditorState } from "@tiptap/react";
|
||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
|
|||||||
@@ -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 { Link } from "@tiptap/extension-link";
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { getAttributes } from "@tiptap/react";
|
import { getAttributes } from "@tiptap/react";
|
||||||
|
|||||||
@@ -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 type { PropsWithChildren } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
flip,
|
flip,
|
||||||
|
|||||||
43
packages/ui/src/RichEditor/PlaceholderExtension.ts
Normal file
43
packages/ui/src/RichEditor/PlaceholderExtension.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 { Blockquote } from "@tiptap/extension-blockquote";
|
||||||
import { Bold } from "@tiptap/extension-bold";
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
import { Code } from "@tiptap/extension-code";
|
import { Code } from "@tiptap/extension-code";
|
||||||
@@ -21,6 +25,8 @@ import { BlockMenu } from "./BlockMenu";
|
|||||||
import { BubbleMenu } from "./BubbleMenu";
|
import { BubbleMenu } from "./BubbleMenu";
|
||||||
import { LinkExtension } from "./LinkExtension";
|
import { LinkExtension } from "./LinkExtension";
|
||||||
import { OptionsMenu } from "./OptionsMenu";
|
import { OptionsMenu } from "./OptionsMenu";
|
||||||
|
import { PlaceholderExtension } from "./PlaceholderExtension";
|
||||||
|
import { SlashCommandExtension } from "./SlashCommandExtension";
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
Document,
|
Document,
|
||||||
@@ -36,6 +42,8 @@ const extensions = [
|
|||||||
Code,
|
Code,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
LinkExtension,
|
LinkExtension,
|
||||||
|
SlashCommandExtension,
|
||||||
|
PlaceholderExtension,
|
||||||
Blockquote,
|
Blockquote,
|
||||||
BulletList,
|
BulletList,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
|
|||||||
145
packages/ui/src/RichEditor/SlashCommandExtension.ts
Normal file
145
packages/ui/src/RichEditor/SlashCommandExtension.ts
Normal 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -52,4 +52,18 @@
|
|||||||
pre { @apply my-4 px-6 py-4 bg-border-solid rounded-lg text-sm; }
|
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; }
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user