fix: editor performance improvements (#1648)

* Switch to useEditorState
* change shouldRerenderOnTransaction to false
This commit is contained in:
Philip Okugbe
2025-09-30 14:04:01 +01:00
committed by GitHub
parent b50e25600a
commit 3fae41a5ca
13 changed files with 369 additions and 177 deletions
@@ -3,6 +3,7 @@ import {
BubbleMenuProps, BubbleMenuProps,
isNodeSelection, isNodeSelection,
useEditor, useEditor,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react"; import { FC, useEffect, useRef, useState } from "react";
import { import {
@@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Bold", name: "Bold",
isActive: () => props.editor.isActive("bold"), isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(), command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold, icon: IconBold,
}, },
{ {
name: "Italic", name: "Italic",
isActive: () => props.editor.isActive("italic"), isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(), command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic, icon: IconItalic,
}, },
{ {
name: "Underline", name: "Underline",
isActive: () => props.editor.isActive("underline"), isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(), command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline, icon: IconUnderline,
}, },
{ {
name: "Strike", name: "Strike",
isActive: () => props.editor.isActive("strike"), isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(), command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough, icon: IconStrikethrough,
}, },
{ {
name: "Code", name: "Code",
isActive: () => props.editor.isActive("code"), isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(), command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode, icon: IconCode,
}, },
@@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = { const commentItem: BubbleMenuItem = {
name: "Comment", name: "Comment",
isActive: () => props.editor.isActive("comment"), isActive: () => editorState?.isComment,
command: () => { command: () => {
const commentId = uuid7(); const commentId = uuid7();
@@ -9,7 +9,8 @@ import {
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
@@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
} }
interface ColorSelectorProps { interface ColorSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -108,12 +109,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen, setIsOpen,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(({ color }) => const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }), editorState[`text_${color}`]
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }), editorState[`highlight_${color}`]
); );
return ( return (
@@ -151,7 +176,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
justify="left" justify="left"
fullWidth fullWidth
rightSection={ rightSection={
editor.isActive("textStyle", { color }) && ( editorState[`text_${color}`] && (
<IconCheck style={{ width: rem(16) }} /> <IconCheck style={{ width: rem(16) }} />
) )
} }
@@ -13,11 +13,12 @@ import {
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Text", name: "Text",
@@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(), editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () => isActive: () =>
editor.isActive("paragraph") && editorState?.isParagraph &&
!editor.isActive("bulletList") && !editorState?.isBulletList &&
!editor.isActive("orderedList"), !editorState?.isOrderedList,
}, },
{ {
name: "Heading 1", name: "Heading 1",
icon: IconH1, icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }), isActive: () => editorState?.isHeading1,
}, },
{ {
name: "Heading 2", name: "Heading 2",
icon: IconH2, icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }), isActive: () => editorState?.isHeading2,
}, },
{ {
name: "Heading 3", name: "Heading 3",
icon: IconH3, icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }), isActive: () => editorState?.isHeading3,
}, },
{ {
name: "To-do List", name: "To-do List",
icon: IconCheckbox, icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(), command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"), isActive: () => editorState?.isTaskItem,
}, },
{ {
name: "Bullet List", name: "Bullet List",
icon: IconList, icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(), command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"), isActive: () => editorState?.isBulletList,
}, },
{ {
name: "Numbered List", name: "Numbered List",
icon: IconListNumbers, icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(), command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"), isActive: () => editorState?.isOrderedList,
}, },
{ {
name: "Blockquote", name: "Blockquote",
@@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph") .toggleNode("paragraph", "paragraph")
.toggleBlockquote() .toggleBlockquote()
.run(), .run(),
isActive: () => editor.isActive("blockquote"), isActive: () => editorState?.isBlockquote,
}, },
{ {
name: "Code", name: "Code",
icon: IconCode, icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"), isActive: () => editorState?.isCodeBlock,
}, },
]; ];
@@ -8,11 +8,12 @@ import {
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
@@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [ const items: BubbleMenuItem[] = [
{ {
name: "Align left", name: "Align left",
isActive: () => editor.isActive({ textAlign: "left" }), isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
isActive: () => editor.isActive({ textAlign: "center" }), isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
isActive: () => editor.isActive({ textAlign: "right" }), isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
{ {
name: "Justify", name: "Justify",
isActive: () => editor.isActive({ textAlign: "justify" }), isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(), command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified, icon: IconAlignJustified,
}, },
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? { const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
name: "Multiple",
};
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
@@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />} rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
> >
<IconAlignLeft style={{ width: rem(16) }} stroke={2} /> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button> </Button>
</Popover.Target> </Popover.Target>
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "prosemirror-model";
@@ -9,7 +10,7 @@ import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip, Divider } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
@@ -35,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout"; const predicate = (node: PMNode) => node.type.name === "callout";
@@ -92,7 +110,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`callout-menu}`} pluginKey={`callout-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -111,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={ variant={editorState?.isInfo ? "light" : "default"}
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -124,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={ variant={editorState?.isSuccess ? "light" : "default"}
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -139,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={ variant={editorState?.isWarning ? "light" : "default"}
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -154,11 +162,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={ variant={editorState?.isDanger ? "light" : "default"}
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
> >
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} />
</ActionIcon> </ActionIcon>
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src; return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
}, },
[editor] [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio'; const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` }); editor.commands.updateAttributes("drawio", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`drawio-menu}`} pluginKey={`drawio-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('drawio')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
} from '@tiptap/react'; useEditorState,
import { useCallback } from 'react'; } from "@tiptap/react";
import { sticky } from 'tippy.js'; import { useCallback } from "react";
import { Node as PMNode } from 'prosemirror-model'; import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts'; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx'; import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const shouldShow = useCallback(
@@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false; return false;
} }
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src; return (
}, editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
[editor]
); );
},
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'excalidraw'; const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection); const parent = findParentNode(predicate)(selection);
if (parent) { if (parent) {
@@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback( const onWidthChange = useCallback(
(value: number) => { (value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` }); editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
}, },
[editor] [editor],
); );
return ( return (
@@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8], offset: [0, 8],
zIndex: 99, zIndex: 99,
popperOptions: { popperOptions: {
modifiers: [{ name: 'flip', enabled: false }], modifiers: [{ name: "flip", enabled: false }],
}, },
plugins: [sticky], plugins: [sticky],
sticky: 'popper', sticky: "popper",
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<div <div
style={{ style={{
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
alignItems: 'center', alignItems: "center",
}} }}
> >
{editor.getAttributes('excalidraw')?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)} )}
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image"; const predicate = (node: PMNode) => node.type.name === "image";
@@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`image-menu}`} pluginKey={`image-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={ variant={editorState?.isAlignLeft ? "light" : "default"}
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={ variant={editorState?.isAlignCenter ? "light" : "default"}
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={ variant={editorState?.isAlignRight ? "light" : "default"}
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editor.getAttributes("image")?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react"; import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link"); return editor.isActive("link");
}, [editor]); }, [editor]);
const { href: link } = editor.getAttributes("link"); const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => { const handleEdit = useCallback(() => {
setShowEdit(true); setShowEdit(true);
@@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs" padding="xs"
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
> >
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} /> <LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card> </Card>
) : ( ) : (
<LinkPreviewPanel <LinkPreviewPanel
url={link} url={editorState?.href}
onClear={onUnsetLink} onClear={onUnsetLink}
onEdit={handleEdit} onEdit={handleEdit}
/> />
@@ -9,7 +9,8 @@ import {
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface TableColorItem { export interface TableColorItem {
@@ -18,7 +19,7 @@ export interface TableColorItem {
} }
interface TableBackgroundColorProps { interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
} }
const TABLE_COLORS: TableColorItem[] = [ const TABLE_COLORS: TableColorItem[] = [
@@ -38,37 +39,50 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
let currentColor = "";
if (ctx.editor.isActive("tableCell")) {
const attrs = ctx.editor.getAttributes("tableCell");
currentColor = attrs.backgroundColor || "";
} else if (ctx.editor.isActive("tableHeader")) {
const attrs = ctx.editor.getAttributes("tableHeader");
currentColor = attrs.backgroundColor || "";
}
return {
currentColor,
isTableCell: ctx.editor.isActive("tableCell"),
isTableHeader: ctx.editor.isActive("tableHeader"),
};
},
});
if (!editor || !editorState) {
return null;
}
const setTableCellBackground = (color: string, colorName: string) => { const setTableCellBackground = (color: string, colorName: string) => {
editor editor
.chain() .chain()
.focus() .focus()
.updateAttributes("tableCell", { .updateAttributes("tableCell", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null backgroundColorName: color ? colorName : null,
}) })
.updateAttributes("tableHeader", { .updateAttributes("tableHeader", {
backgroundColor: color || null, backgroundColor: color || null,
backgroundColorName: color ? colorName : null backgroundColorName: color ? colorName : null,
}) })
.run(); .run();
setOpened(false); setOpened(false);
}; };
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return ( return (
<Popover <Popover
width={200} width={200}
@@ -123,7 +137,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer", cursor: "pointer",
}} }}
> >
{currentColor === item.color && ( {editorState.currentColor === item.color && (
<IconCheck <IconCheck
size={18} size={18}
style={{ style={{
@@ -9,15 +9,15 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Popover, Popover,
rem,
ScrollArea, ScrollArea,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps { interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>; editor: Editor | null;
} }
interface AlignmentItem { interface AlignmentItem {
@@ -32,25 +32,44 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = React.useState(false); const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: AlignmentItem[] = [ const items: AlignmentItem[] = [
{ {
name: "Align left", name: "Align left",
value: "left", value: "left",
isActive: () => editor.isActive({ textAlign: "left" }), isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(), command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft, icon: IconAlignLeft,
}, },
{ {
name: "Align center", name: "Align center",
value: "center", value: "center",
isActive: () => editor.isActive({ textAlign: "center" }), isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(), command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter, icon: IconAlignCenter,
}, },
{ {
name: "Align right", name: "Align right",
value: "right", value: "right",
isActive: () => editor.isActive({ textAlign: "right" }), isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(), command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight, icon: IconAlignRight,
}, },
@@ -64,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened} onChange={setOpened}
position="bottom" position="bottom"
withArrow withArrow
transitionProps={{ transition: 'pop' }} transitionProps={{ transition: "pop" }}
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text alignment")} withArrow> <Tooltip label={t("Text alignment")} withArrow>
@@ -87,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index} key={index}
variant="default" variant="default"
leftSection={<item.icon size={16} />} leftSection={<item.icon size={16} />}
rightSection={ rightSection={item.isActive() && <IconCheck size={16} />}
item.isActive() && <IconCheck size={16} />
}
justify="left" justify="left"
fullWidth fullWidth
onClick={() => { onClick={() => {
@@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu, BubbleMenu as BaseBubbleMenu,
findParentNode, findParentNode,
posToDOMRect, posToDOMRect,
useEditorState,
} from "@tiptap/react"; } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { sticky } from "tippy.js"; import { sticky } from "tippy.js";
@@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor], [editor],
); );
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => { const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state; const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video"; const predicate = (node: PMNode) => node.type.name === "video";
@@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
pluginKey={`video-menu}`} pluginKey={`video-menu`}
updateDelay={0} updateDelay={0}
tippyOptions={{ tippyOptions={{
getReferenceClientRect, getReferenceClientRect,
@@ -103,9 +123,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft} onClick={alignVideoLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={ variant={editorState?.isAlignLeft ? "light" : "default"}
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter} onClick={alignVideoCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={ variant={editorState?.isAlignCenter ? "light" : "default"}
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight} onClick={alignVideoRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={ variant={editorState?.isAlignRight ? "light" : "default"}
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </ActionIcon.Group>
{editor.getAttributes("video")?.width && ( {editorState?.width && (
<NodeWidthResize <NodeWidthResize onChange={onWidthChange} value={editorState.width} />
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
)} )}
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -7,7 +7,12 @@ import {
onAuthenticationFailedParameters, onAuthenticationFailedParameters,
WebSocketStatus, WebSocketStatus,
} from "@hocuspocus/provider"; } from "@hocuspocus/provider";
import { EditorContent, EditorProvider, useEditor } from "@tiptap/react"; import {
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import { import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
@@ -50,7 +55,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts"; import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from '@/features/search/constants.ts'; import { searchSpotlight } from "@/features/search/constants.ts";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -77,7 +82,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false); const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`; const documentName = `page.${pageId}`;
@@ -213,17 +218,17 @@ export default function PageEditor({
extensions, extensions,
editable, editable,
immediatelyRender: true, immediatelyRender: true,
shouldRerenderOnTransaction: true, shouldRerenderOnTransaction: false,
editorProps: { editorProps: {
scrollThreshold: 80, scrollThreshold: 80,
scrollMargin: 80, scrollMargin: 80,
handleDOMEvents: { handleDOMEvents: {
keydown: (_view, event) => { keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault(); event.preventDefault();
return true; return true;
} }
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyK') { if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open(); searchSpotlight.open();
return true; return true;
} }
@@ -268,9 +273,16 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, remoteProvider] [pageId, editable, remoteProvider],
); );
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
return ctx.editor?.isEditable ?? false;
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => { const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]); const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@@ -306,7 +318,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent handleActiveCommentEvent,
); );
}; };
}, []); }, []);
@@ -389,7 +401,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} /> <SearchAndReplaceDialog editor={editor} editable={editable} />
)} )}
{editor && editor.isEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />