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 {
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 { 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,
|
||||
|
||||
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; }
|
||||
|
||||
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