From 34d64ecb7507b610ecfc29a410f839398e4df421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 23 Mar 2026 10:28:19 +0400 Subject: [PATCH] Add side actions for insertin new / editing blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- package-lock.json | 22 ++ packages/ui/package.json | 1 + packages/ui/src/RichEditor/BlockMenu.tsx | 234 ++++++++++++++ packages/ui/src/RichEditor/BubbleMenu.tsx | 64 ++++ packages/ui/src/RichEditor/LinkExtension.ts | 45 +++ packages/ui/src/RichEditor/MenuButton.tsx | 28 ++ packages/ui/src/RichEditor/OptionsMenu.tsx | 325 ++++++++++++++++++++ packages/ui/src/RichEditor/RichEditor.tsx | 159 ++-------- packages/ui/src/theme.css | 22 +- 9 files changed, 749 insertions(+), 151 deletions(-) create mode 100644 packages/ui/src/RichEditor/BlockMenu.tsx create mode 100644 packages/ui/src/RichEditor/BubbleMenu.tsx create mode 100644 packages/ui/src/RichEditor/LinkExtension.ts create mode 100644 packages/ui/src/RichEditor/MenuButton.tsx create mode 100644 packages/ui/src/RichEditor/OptionsMenu.tsx diff --git a/package-lock.json b/package-lock.json index 03f9ed5e9..aecea504d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/ui/package.json b/packages/ui/package.json index 23da62a55..c93267105 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/RichEditor/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu.tsx new file mode 100644 index 000000000..4b5dbd876 --- /dev/null +++ b/packages/ui/src/RichEditor/BlockMenu.tsx @@ -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; +}; + +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 rafId = useRef(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) => ReturnType) => { + 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 ( + <> + + {menuOpen && ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + handleAction(chain => chain.toggleHeading({ level: 1 }))} + /> + handleAction(chain => chain.toggleHeading({ level: 2 }))} + /> + handleAction(chain => chain.toggleHeading({ level: 3 }))} + /> + handleAction(chain => chain.toggleBulletList())} + /> + handleAction(chain => chain.toggleOrderedList())} + /> + handleAction(chain => chain.toggleCode())} + /> + handleAction(chain => chain.toggleCodeBlock())} + /> + handleAction(chain => chain.toggleBlockquote())} + /> + handleAction(chain => chain.setHorizontalRule())} + /> +
+ )} + + ); +} diff --git a/packages/ui/src/RichEditor/BubbleMenu.tsx b/packages/ui/src/RichEditor/BubbleMenu.tsx new file mode 100644 index 000000000..45b336401 --- /dev/null +++ b/packages/ui/src/RichEditor/BubbleMenu.tsx @@ -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; +}; + +export function BubbleMenu(props: BubbleMenuProps) { + const { editor } = props; + + return ( + + editor.chain().focus().toggleBold().run()} + /> + editor.chain().focus().toggleItalic().run()} + /> + editor.chain().focus().toggleUnderline().run()} + /> + editor.chain().focus().toggleStrike().run()} + /> + editor.chain().focus().toggleCode().run()} + /> + { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + return; + } + const url = window.prompt("URL"); + if (url) { + editor.chain().focus().setLink({ href: url }).run(); + } + }} + /> + + ); +} diff --git a/packages/ui/src/RichEditor/LinkExtension.ts b/packages/ui/src/RichEditor/LinkExtension.ts new file mode 100644 index 000000000..c884991b3 --- /dev/null +++ b/packages/ui/src/RichEditor/LinkExtension.ts @@ -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, +}); diff --git a/packages/ui/src/RichEditor/MenuButton.tsx b/packages/ui/src/RichEditor/MenuButton.tsx new file mode 100644 index 000000000..7d9e6f2b3 --- /dev/null +++ b/packages/ui/src/RichEditor/MenuButton.tsx @@ -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 ( + + ); +} diff --git a/packages/ui/src/RichEditor/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu.tsx new file mode 100644 index 000000000..5f2d53ce3 --- /dev/null +++ b/packages/ui/src/RichEditor/OptionsMenu.tsx @@ -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) { + view.dragging = { slice, move: true }; +} + +type OptionsMenuProps = { + editor: ReturnType; +}; + +export function OptionsMenu({ editor }: OptionsMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [triggerEl, setTriggerEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(null); + const [hoveredBlock, setHoveredBlock] = useState(null); + const rafId = useRef(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) => { + 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) => ReturnType, + ) => { + 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) => { + 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 ( + <> + + {menuOpen && ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + handleAction(chain => chain.toggleHeading({ level: 1 }))} + /> + handleAction(chain => chain.toggleHeading({ level: 2 }))} + /> + handleAction(chain => chain.toggleHeading({ level: 3 }))} + /> + handleAction(chain => chain.toggleBulletList())} + /> + handleAction(chain => chain.toggleOrderedList())} + /> + handleAction(chain => chain.toggleCode())} + /> + handleAction(chain => chain.toggleCodeBlock())} + /> + handleAction(chain => chain.toggleBlockquote())} + /> +
+ )} + + ); +} diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 1de2569c0..66f79ecd5 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -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 ( - - ); -} +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(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) { /> - - - editor.chain().focus().toggleHeading({ level: 1 }).run()} - /> - - editor.chain().focus().toggleHeading({ level: 2 }).run()} - /> - - editor.chain().focus().toggleHeading({ level: 3 }).run()} - /> - editor.chain().focus().toggleBulletList().run()} - /> - editor.chain().focus().toggleOrderedList().run()} - /> - editor.chain().focus().toggleCode().run()} - /> - editor.chain().focus().toggleCodeBlock().run()} - /> - editor.chain().focus().toggleBlockquote().run()} - /> - editor.chain().focus().setHorizontalRule().run()} - /> - + + - + ); } diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css index d7e9b1019..4bb339abe 100644 --- a/packages/ui/src/theme.css +++ b/packages/ui/src/theme.css @@ -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 {