Add side actions for insertin new / editing blocks
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
234
packages/ui/src/RichEditor/BlockMenu.tsx
Normal file
234
packages/ui/src/RichEditor/BlockMenu.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
packages/ui/src/RichEditor/BubbleMenu.tsx
Normal file
64
packages/ui/src/RichEditor/BubbleMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
packages/ui/src/RichEditor/LinkExtension.ts
Normal file
45
packages/ui/src/RichEditor/LinkExtension.ts
Normal 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,
|
||||
});
|
||||
28
packages/ui/src/RichEditor/MenuButton.tsx
Normal file
28
packages/ui/src/RichEditor/MenuButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
packages/ui/src/RichEditor/OptionsMenu.tsx
Normal file
325
packages/ui/src/RichEditor/OptionsMenu.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user