Add side actions for insertin new / editing blocks

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-23 10:28:19 +04:00
parent 21eb2e5197
commit 34d64ecb75
9 changed files with 749 additions and 151 deletions

22
package-lock.json generated
View File

@@ -1465,6 +1465,21 @@
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.19",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
@@ -17749,6 +17764,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -19680,6 +19701,7 @@
"version": "1.0.0",
"dependencies": {
"@ariakit/react": "^0.4.17",
"@floating-ui/react": "^0.27.19",
"@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -13,6 +13,7 @@
},
"dependencies": {
"@ariakit/react": "^0.4.17",
"@floating-ui/react": "^0.27.19",
"@probo/helpers": "1.0.0",
"@probo/i18n": "1.0.0",
"@radix-ui/react-alert-dialog": "^1.1.15",

View File

@@ -0,0 +1,234 @@
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { type useEditor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { IconPlusSmall } from "../Atoms/Icons";
import { MenuButton } from "./MenuButton";
const blockMenuVariants = tv({
slots: {
trigger: [
"z-50 flex size-6 items-center justify-center",
"rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary text-xl font-light cursor-pointer",
],
menu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-50"],
},
});
const { trigger, menu } = blockMenuVariants();
function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement | null {
let current: Element | null = element;
while (current?.parentElement && current.parentElement !== editorDom) {
current = current.parentElement;
}
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
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 rafId = useRef<number | null>(null);
const {
refs: triggerRefs,
floatingStyles: triggerStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: "left-start",
middleware: [offset(32)],
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 (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
if (!editor.isEditable) {
setHoveredBlock(null);
return;
}
const elements = editorDom.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
let block: HTMLElement | null = null;
for (const el of elements) {
if (!editorDom.contains(el)) continue;
block = findClosestRootBlock(el, editorDom);
if (block) break;
}
if (block) {
setHoveredBlock(block);
}
});
};
editorDom.addEventListener("mousemove", onMouseMove);
return () => {
editorDom.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, menuOpen]);
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
const shouldShow = hoveredBlock != null || menuOpen;
if (!editor || !shouldShow) return null;
const handleAction = (applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>) => {
if (!hoveredBlock) return;
try {
const pos = editor.view.posAtDOM(hoveredBlock, 0);
const $pos = editor.state.doc.resolve(pos);
const rootPos = $pos.before(1);
const rootNode = $pos.node(1);
const insertPos = rootPos + rootNode.nodeSize;
editor.chain()
.focus()
.insertContentAt(insertPos, { type: "paragraph" })
.setTextSelection(insertPos + 1)
.run();
applyCommand(editor.chain()).run();
} 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()}
>
<IconPlusSmall size={16} />
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
<MenuButton
label="H1"
active={editor.isActive("heading", { level: 1 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
/>
<MenuButton
label="H2"
active={editor.isActive("heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
/>
<MenuButton
label="H3"
active={editor.isActive("heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
/>
<MenuButton
label="Bullet List"
active={editor.isActive("bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())}
/>
<MenuButton
label="Ordered List"
active={editor.isActive("orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
/>
<MenuButton
label="Code"
active={editor.isActive("code")}
onClick={() => handleAction(chain => chain.toggleCode())}
/>
<MenuButton
label="Code Block"
active={editor.isActive("codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
/>
<MenuButton
label="Blockquote"
active={editor.isActive("blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
/>
<MenuButton
label="Divider"
onClick={() => handleAction(chain => chain.setHorizontalRule())}
/>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,64 @@
import type { useEditor } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { tv } from "tailwind-variants";
import { MenuButton } from "./MenuButton";
const bubbleMenuVariants = tv({
base: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
});
type BubbleMenuProps = {
editor: ReturnType<typeof useEditor>;
};
export function BubbleMenu(props: BubbleMenuProps) {
const { editor } = props;
return (
<BaseBubbleMenu
editor={editor}
className={bubbleMenuVariants()}
>
<MenuButton
label="Bold"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<MenuButton
label="Italic"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<MenuButton
label="Underline"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
/>
<MenuButton
label="Strike"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<MenuButton
label="Code"
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<MenuButton
label="Link"
active={editor.isActive("link")}
onClick={() => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt("URL");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}}
/>
</BaseBubbleMenu>
);
}

View File

@@ -0,0 +1,45 @@
import { Link } from "@tiptap/extension-link";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { getAttributes } from "@tiptap/react";
export const LinkExtension = Link.extend({
addProseMirrorPlugins: () => {
return [
new Plugin({
key: new PluginKey("handleControlClick"),
props: {
handleKeyDown: (view, event) => {
if (event.key === "Control" || event.key === "Meta") {
view.dom.classList.add("pointer-on-hovered-link");
}
},
handleDOMEvents: {
keyup: (view, event) => {
if (event.key === "Control" || event.key === "Meta") {
view.dom.classList.remove("pointer-on-hovered-link");
}
},
},
handleClick: (view, _, event) => {
const { ctrlKey, metaKey } = event; // Check for Ctrl (Windows) or Cmd (Mac)
const keyPressed = ctrlKey || metaKey;
if (keyPressed) {
// Get attributes of the mark at the clicked position
const attrs = getAttributes(view.state, "link");
const link = (event.target as Element | null)?.closest("a");
if (link && attrs.href) {
window.open(attrs.href as string, "_blank", "noopener,noreferrer"); // Open link in a new tab
return true; // Handle the event
}
}
return false; // Let other handlers run
},
},
}),
];
},
}).configure({
openOnClick: false,
});

View File

@@ -0,0 +1,28 @@
import { tv } from "tailwind-variants";
const menuButtonVariants = tv({
base: ["px-2 py-1 text-sm rounded-sm font-semibold bg-level-0 hover:bg-subtle"],
variants: {
active: {
true: ["bg-active"],
},
},
});
type MenuButtonProps = {
label: string;
active?: boolean;
onClick: () => void;
};
export function MenuButton({ label, active, onClick }: MenuButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={menuButtonVariants({ active })}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,325 @@
import {
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { type useEditor } from "@tiptap/react";
import { type DragEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { IconChevronGrabberVertical } from "../Atoms/Icons";
import { MenuButton } from "./MenuButton";
const optionsMenuVariants = tv({
slots: {
trigger: [
"z-50 flex size-6 items-center justify-center",
"rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary cursor-grab",
],
menu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-50"],
},
});
const { trigger, menu } = optionsMenuVariants();
function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement | null {
let current: Element | null = element;
while (current?.parentElement && current.parentElement !== editorDom) {
current = current.parentElement;
}
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
function startDrag(view: EditorView, slice: ReturnType<NodeSelection["content"]>) {
view.dragging = { slice, move: true };
}
type OptionsMenuProps = {
editor: ReturnType<typeof useEditor>;
};
export function OptionsMenu({ editor }: OptionsMenuProps) {
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 rafId = useRef<number | null>(null);
const {
refs: triggerRefs,
floatingStyles: triggerStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: "left-start",
middleware: [offset(8)],
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 (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
if (!editor.isEditable) {
setHoveredBlock(null);
return;
}
const elements = editorDom.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
let block: HTMLElement | null = null;
for (const el of elements) {
if (!editorDom.contains(el)) continue;
block = findClosestRootBlock(el, editorDom);
if (block) break;
}
if (block) {
setHoveredBlock(block);
}
});
};
editorDom.addEventListener("mousemove", onMouseMove);
return () => {
editorDom.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, menuOpen]);
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
const shouldShow = hoveredBlock != null || menuOpen;
if (!editor || !shouldShow) return null;
const getNodeAtHoveredBlock = () => {
if (!hoveredBlock) return null;
try {
const pos = editor.view.posAtDOM(hoveredBlock, 0);
const $pos = editor.state.doc.resolve(pos);
return { node: $pos.node(1), pos: $pos.before(1) };
} catch {
return null;
}
};
const isNodeType = (type: string, attrs?: Record<string, unknown>) => {
const data = getNodeAtHoveredBlock();
if (!data) return false;
if (data.node.type.name !== type) return false;
if (attrs) {
return Object.entries(attrs).every(
([key, value]) => data.node.attrs[key] === value,
);
}
return true;
};
const handleAction = (
applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>,
) => {
const data = getNodeAtHoveredBlock();
if (!data) return;
try {
if (!data.node.isTextblock) {
let textBlock = data.node.firstChild;
if (!textBlock) return;
while (!textBlock.isTextblock && textBlock.firstChild) {
textBlock = textBlock.firstChild;
}
if (!textBlock.isTextblock) return;
const firstChildSize = data.node.firstChild!.nodeSize;
const paragraph = editor.state.schema.nodes.paragraph.create(
null,
textBlock.content,
);
editor.chain()
.focus()
.command(({ tr }) => {
tr.insert(data.pos, paragraph);
const wrapperPos = data.pos + paragraph.nodeSize;
const wrapperNode = tr.doc.nodeAt(wrapperPos);
if (!wrapperNode) return false;
if (wrapperNode.childCount <= 1) {
tr.delete(wrapperPos, wrapperPos + wrapperNode.nodeSize);
} else {
tr.delete(wrapperPos + 1, wrapperPos + 1 + firstChildSize);
}
return true;
})
.run();
}
const $near = editor.state.doc.resolve(data.pos + 1);
const textPos = TextSelection.near($near).from;
applyCommand(
editor.chain()
.focus()
.setTextSelection(textPos),
).run();
} catch {
// Block may no longer be in the document
}
setMenuOpen(false);
};
const onDragStart = (e: DragEvent<HTMLButtonElement>) => {
const data = getNodeAtHoveredBlock();
if (!data || !hoveredBlock) return;
try {
const view = editor.view;
const selection = NodeSelection.create(view.state.doc, data.pos);
const slice = selection.content();
const { tr } = view.state;
tr.setSelection(selection);
view.dispatch(tr);
if (e.dataTransfer) {
e.dataTransfer.clearData();
e.dataTransfer.setData("text/plain", "");
e.dataTransfer.effectAllowed = "move";
const wrapper = document.createElement("div");
wrapper.append(hoveredBlock.cloneNode(true));
wrapper.style.position = "absolute";
wrapper.style.top = "-10000px";
document.body.append(wrapper);
e.dataTransfer.setDragImage(wrapper, 0, 0);
document.addEventListener("drop", () => wrapper.remove(), { once: true });
}
startDrag(view, slice);
} catch {
// Block may no longer be in the document
}
};
return (
<>
<button
ref={(node) => {
triggerRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
{...getReferenceProps()}
draggable
onDragStart={onDragStart}
onDragEnd={() => setHoveredBlock(null)}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<IconChevronGrabberVertical size={16} />
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
<MenuButton
label="H1"
active={isNodeType("heading", { level: 1 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
/>
<MenuButton
label="H2"
active={isNodeType("heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
/>
<MenuButton
label="H3"
active={isNodeType("heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
/>
<MenuButton
label="Bullet List"
active={isNodeType("bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())}
/>
<MenuButton
label="Ordered List"
active={isNodeType("orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
/>
<MenuButton
label="Code"
active={isNodeType("code")}
onClick={() => handleAction(chain => chain.toggleCode())}
/>
<MenuButton
label="Code Block"
active={isNodeType("codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
/>
<MenuButton
label="Blockquote"
active={isNodeType("blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
/>
</div>
)}
</>
);
}

View File

@@ -7,73 +7,26 @@ import { HardBreak } from "@tiptap/extension-hard-break";
import { Heading } from "@tiptap/extension-heading";
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
import { Italic } from "@tiptap/extension-italic";
import { Link } from "@tiptap/extension-link";
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Strike } from "@tiptap/extension-strike";
import { Text } from "@tiptap/extension-text";
import { Underline } from "@tiptap/extension-underline";
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { type Content, EditorContent, getAttributes, useEditor, useEditorState } from "@tiptap/react";
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
import { type ComponentProps, useEffect } from "react";
import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { type ComponentProps, useEffect, useRef } from "react";
import { tv } from "tailwind-variants";
export const ControlClickLink = Link.extend({
addProseMirrorPlugins: () => {
return [
new Plugin({
key: new PluginKey("handleControlClick"),
props: {
handleKeyDown: (view, event) => {
if (event.key === "Control" || event.key === "Meta") {
view.dom.classList.add("pointer-on-hovered-link");
}
},
handleDOMEvents: {
keyup: (view, event) => {
if (event.key === "Control" || event.key === "Meta") {
view.dom.classList.remove("pointer-on-hovered-link");
}
},
},
handleClick: (view, _, event) => {
const { ctrlKey, metaKey } = event; // Check for Ctrl (Windows) or Cmd (Mac)
const keyPressed = ctrlKey || metaKey;
if (keyPressed) {
// Get attributes of the mark at the clicked position
const attrs = getAttributes(view.state, "link");
const link = (event.target as Element | null)?.closest("a");
if (link && attrs.href) {
window.open(attrs.href as string, "_blank", "noopener,noreferrer"); // Open link in a new tab
return true; // Handle the event
}
}
return false; // Let other handlers run
},
},
}),
];
},
}).configure({
openOnClick: false,
});
import { BlockMenu } from "./BlockMenu";
import { LinkExtension } from "./LinkExtension";
import { MenuButton } from "./MenuButton";
import { OptionsMenu } from "./OptionsMenu";
const extensions = [
Document,
Paragraph.configure({
HTMLAttributes: {
class: "text-base py-2",
},
}),
Text.configure({
HTMLAttributes: {
class: "text-base",
},
}),
Paragraph,
Text,
Heading.configure({
levels: [1, 2, 3],
}),
@@ -83,7 +36,7 @@ const extensions = [
Underline,
Code,
CodeBlock,
ControlClickLink,
LinkExtension,
Blockquote,
BulletList,
OrderedList,
@@ -98,38 +51,11 @@ const extensions = [
const richEditorVariants = tv({
slots: {
bubbleMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
floatingMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
menuButton: ["px-2 py-1 text-sm rounded-sm font-semibold bg-level-0 hover:bg-subtle"],
editor: ["h-full"],
},
variants: {
active: {
true: {
menuButton: ["bg-active"],
},
},
editor: ["h-full px-12"],
},
});
const { bubbleMenu, floatingMenu, editor: editorVariants, menuButton } = richEditorVariants();
type MenuButtonProps = {
label: string;
active?: boolean;
onClick: () => void;
};
function MenuButton({ label, active, onClick }: MenuButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={menuButton({ active })}
>
{label}
</button>
);
}
const { bubbleMenu, editor: editorVariants } = richEditorVariants();
type RichEditorProps = ComponentProps<"div"> & {
content: string;
@@ -140,6 +66,8 @@ type RichEditorProps = ComponentProps<"div"> & {
export function RichEditor(props: RichEditorProps) {
const { className, content, disabled = false, onChangeContent } = props;
const previousContentRef = useRef<string>(content);
const editor = useEditor({
editorProps: {
attributes: {
@@ -159,7 +87,8 @@ export function RichEditor(props: RichEditorProps) {
});
useEffect(() => {
if (watchedContent !== content) {
if (watchedContent !== previousContentRef.current) {
previousContentRef.current = watchedContent;
onChangeContent(watchedContent);
}
}, [content, watchedContent, onChangeContent]);
@@ -211,60 +140,10 @@ export function RichEditor(props: RichEditorProps) {
/>
</BubbleMenu>
<FloatingMenu
editor={editor}
className={floatingMenu()}
>
<MenuButton
label="H1"
active={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()}
/>
<MenuButton
label="H2"
active={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()}
/>
<MenuButton
label="H3"
active={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()}
/>
<MenuButton
label="Bullet List"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<MenuButton
label="Ordered List"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<MenuButton
label="Code"
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<MenuButton
label="Code Block"
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
/>
<MenuButton
label="Blockquote"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
/>
<MenuButton
label="Divider"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
/>
</FloatingMenu>
<BlockMenu editor={editor} />
<OptionsMenu editor={editor} />
<EditorContent className={editorVariants()} editor={editor} />
<EditorContent className="h-full" editor={editor} />
</div>
);
}

View File

@@ -284,36 +284,36 @@ button[data-cell]:focus-visible {
outline-style: none !important;
}
.tiptap p { @apply py-2; }
.tiptap p { @apply my-2; }
.tiptap strong { @apply font-semibold; }
.tiptap h1 {
@apply text-4xl font-semibold pt-4;
@apply text-2xl font-semibold mt-4;
}
.tiptap h2 {
@apply text-3xl font-semibold pt-3;
@apply text-xl font-semibold mt-3;
}
.tiptap h3 {
@apply text-2xl font-semibold pt-2;
@apply text-lg font-semibold mt-2;
}
.tiptap ul {
@apply list-disc list-inside py-2;
@apply list-disc list-inside my-2;
li {
p { @apply inline-block py-0 }
p { @apply inline-block my-0 }
}
li::marker { @apply mx-2!; }
}
.tiptap ol {
@apply list-decimal list-inside py-2;
@apply list-decimal list-inside my-2;
li {
p { @apply inline-block py-0 }
p { @apply inline-block my-0 }
}
li::marker { @apply mx-2!; }
}
@@ -327,8 +327,8 @@ button[data-cell]:focus-visible {
}
.tiptap blockquote {
@apply py-2 pl-4 border-l-4 border-l-border-solid;
p { @apply py-0; }
@apply my-2 pl-4 border-l-4 border-l-border-solid;
p { @apply my-0; }
}
.tiptap hr {
@@ -336,7 +336,7 @@ button[data-cell]:focus-visible {
}
.tiptap pre {
@apply py-4 px-6 bg-border-solid rounded-lg text-sm;
@apply my-4 px-6 py-4 bg-border-solid rounded-lg text-sm;
}
.tiptap code {