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"
|
"@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": {
|
"node_modules/@floating-ui/react-dom": {
|
||||||
"version": "2.1.8",
|
"version": "2.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
"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"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
@@ -19680,6 +19701,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.17",
|
"@ariakit/react": "^0.4.17",
|
||||||
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@probo/helpers": "1.0.0",
|
"@probo/helpers": "1.0.0",
|
||||||
"@probo/i18n": "1.0.0",
|
"@probo/i18n": "1.0.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.17",
|
"@ariakit/react": "^0.4.17",
|
||||||
|
"@floating-ui/react": "^0.27.19",
|
||||||
"@probo/helpers": "1.0.0",
|
"@probo/helpers": "1.0.0",
|
||||||
"@probo/i18n": "1.0.0",
|
"@probo/i18n": "1.0.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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 { Heading } from "@tiptap/extension-heading";
|
||||||
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
|
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
|
||||||
import { Italic } from "@tiptap/extension-italic";
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
import { Link } from "@tiptap/extension-link";
|
|
||||||
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
|
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
import { Strike } from "@tiptap/extension-strike";
|
import { Strike } from "@tiptap/extension-strike";
|
||||||
import { Text } from "@tiptap/extension-text";
|
import { Text } from "@tiptap/extension-text";
|
||||||
import { Underline } from "@tiptap/extension-underline";
|
import { Underline } from "@tiptap/extension-underline";
|
||||||
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
|
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
|
||||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
||||||
import { type Content, EditorContent, getAttributes, useEditor, useEditorState } from "@tiptap/react";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
|
import { type ComponentProps, useEffect, useRef } from "react";
|
||||||
import { type ComponentProps, useEffect } from "react";
|
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const ControlClickLink = Link.extend({
|
import { BlockMenu } from "./BlockMenu";
|
||||||
addProseMirrorPlugins: () => {
|
import { LinkExtension } from "./LinkExtension";
|
||||||
return [
|
import { MenuButton } from "./MenuButton";
|
||||||
new Plugin({
|
import { OptionsMenu } from "./OptionsMenu";
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
Document,
|
Document,
|
||||||
Paragraph.configure({
|
Paragraph,
|
||||||
HTMLAttributes: {
|
Text,
|
||||||
class: "text-base py-2",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Text.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "text-base",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Heading.configure({
|
Heading.configure({
|
||||||
levels: [1, 2, 3],
|
levels: [1, 2, 3],
|
||||||
}),
|
}),
|
||||||
@@ -83,7 +36,7 @@ const extensions = [
|
|||||||
Underline,
|
Underline,
|
||||||
Code,
|
Code,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
ControlClickLink,
|
LinkExtension,
|
||||||
Blockquote,
|
Blockquote,
|
||||||
BulletList,
|
BulletList,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
@@ -98,38 +51,11 @@ const extensions = [
|
|||||||
const richEditorVariants = tv({
|
const richEditorVariants = tv({
|
||||||
slots: {
|
slots: {
|
||||||
bubbleMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
|
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"],
|
editor: ["h-full px-12"],
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { bubbleMenu, floatingMenu, editor: editorVariants, menuButton } = richEditorVariants();
|
const { bubbleMenu, editor: editorVariants } = 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RichEditorProps = ComponentProps<"div"> & {
|
type RichEditorProps = ComponentProps<"div"> & {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -140,6 +66,8 @@ type RichEditorProps = ComponentProps<"div"> & {
|
|||||||
export function RichEditor(props: RichEditorProps) {
|
export function RichEditor(props: RichEditorProps) {
|
||||||
const { className, content, disabled = false, onChangeContent } = props;
|
const { className, content, disabled = false, onChangeContent } = props;
|
||||||
|
|
||||||
|
const previousContentRef = useRef<string>(content);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -159,7 +87,8 @@ export function RichEditor(props: RichEditorProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (watchedContent !== content) {
|
if (watchedContent !== previousContentRef.current) {
|
||||||
|
previousContentRef.current = watchedContent;
|
||||||
onChangeContent(watchedContent);
|
onChangeContent(watchedContent);
|
||||||
}
|
}
|
||||||
}, [content, watchedContent, onChangeContent]);
|
}, [content, watchedContent, onChangeContent]);
|
||||||
@@ -211,60 +140,10 @@ export function RichEditor(props: RichEditorProps) {
|
|||||||
/>
|
/>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
|
|
||||||
<FloatingMenu
|
<BlockMenu editor={editor} />
|
||||||
editor={editor}
|
<OptionsMenu 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>
|
|
||||||
|
|
||||||
<EditorContent className={editorVariants()} editor={editor} />
|
<EditorContent className="h-full" editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,36 +284,36 @@ button[data-cell]:focus-visible {
|
|||||||
outline-style: none !important;
|
outline-style: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap p { @apply py-2; }
|
.tiptap p { @apply my-2; }
|
||||||
|
|
||||||
.tiptap strong { @apply font-semibold; }
|
.tiptap strong { @apply font-semibold; }
|
||||||
|
|
||||||
.tiptap h1 {
|
.tiptap h1 {
|
||||||
@apply text-4xl font-semibold pt-4;
|
@apply text-2xl font-semibold mt-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap h2 {
|
.tiptap h2 {
|
||||||
@apply text-3xl font-semibold pt-3;
|
@apply text-xl font-semibold mt-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap h3 {
|
.tiptap h3 {
|
||||||
@apply text-2xl font-semibold pt-2;
|
@apply text-lg font-semibold mt-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap ul {
|
.tiptap ul {
|
||||||
@apply list-disc list-inside py-2;
|
@apply list-disc list-inside my-2;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
p { @apply inline-block py-0 }
|
p { @apply inline-block my-0 }
|
||||||
}
|
}
|
||||||
li::marker { @apply mx-2!; }
|
li::marker { @apply mx-2!; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap ol {
|
.tiptap ol {
|
||||||
@apply list-decimal list-inside py-2;
|
@apply list-decimal list-inside my-2;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
p { @apply inline-block py-0 }
|
p { @apply inline-block my-0 }
|
||||||
}
|
}
|
||||||
li::marker { @apply mx-2!; }
|
li::marker { @apply mx-2!; }
|
||||||
}
|
}
|
||||||
@@ -327,8 +327,8 @@ button[data-cell]:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap blockquote {
|
.tiptap blockquote {
|
||||||
@apply py-2 pl-4 border-l-4 border-l-border-solid;
|
@apply my-2 pl-4 border-l-4 border-l-border-solid;
|
||||||
p { @apply py-0; }
|
p { @apply my-0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap hr {
|
.tiptap hr {
|
||||||
@@ -336,7 +336,7 @@ button[data-cell]:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap pre {
|
.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 {
|
.tiptap code {
|
||||||
|
|||||||
Reference in New Issue
Block a user