editor improvements

* add callout, youtube embed, image, video, table, detail, math
* fix attachments module
* other fixes
This commit is contained in:
Philipinho
2024-06-20 14:57:00 +01:00
parent c7925739cb
commit 1f4bd129a8
74 changed files with 5205 additions and 381 deletions

View File

@ -24,6 +24,7 @@ import {
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { v4 as uuidv4 } from "uuid";
import { isCellSelection } from "@docmost/editor-ext";
export interface BubbleMenuItem {
name: string;
@ -103,6 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
editor.isActive("image") ||
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showCommentPopupRef?.current
) {
return false;

View File

@ -0,0 +1,136 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
export function CalloutMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("callout");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const setCalloutType = useCallback(
(calloutType: CalloutType) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutType(calloutType)
.run();
},
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 2],
placement: "bottom",
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Info">
<ActionIcon
onClick={() => setCalloutType("info")}
size="lg"
aria-label="Info"
variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
>
<IconInfoCircleFilled size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Success">
<ActionIcon
onClick={() => setCalloutType("success")}
size="lg"
aria-label="Success"
variant={
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
>
<IconCircleCheckFilled size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Warning">
<ActionIcon
onClick={() => setCalloutType("warning")}
size="lg"
aria-label="Warning"
variant={
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
>
<IconAlertTriangleFilled size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Danger">
<ActionIcon
onClick={() => setCalloutType("danger")}
size="lg"
aria-label="Danger"
variant={
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
>
<IconCircleXFilled size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);
}
export default CalloutMenu;

View File

@ -0,0 +1,70 @@
import {
Editor,
NodeViewContent,
NodeViewProps,
NodeViewWrapper,
} from "@tiptap/react";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
} from "@tabler/icons-react";
import { Alert } from "@mantine/core";
import classes from "./callout.module.css";
import { CalloutType } from "@docmost/editor-ext";
export default function CalloutView(props: NodeViewProps) {
const { node } = props;
const { type } = node.attrs;
return (
<NodeViewWrapper>
<Alert
variant="light"
title=""
color={getCalloutColor(type)}
icon={getCalloutIcon(type)}
p="xs"
classNames={{
message: classes.message,
icon: classes.icon,
}}
>
<NodeViewContent />
</Alert>
</NodeViewWrapper>
);
}
function getCalloutIcon(type: CalloutType) {
switch (type) {
case "info":
return <IconInfoCircleFilled />;
case "success":
return <IconCircleCheckFilled />;
case "warning":
return <IconAlertTriangleFilled />;
case "danger":
return <IconCircleXFilled />;
default:
return <IconInfoCircleFilled />;
}
}
function getCalloutColor(type: CalloutType) {
switch (type) {
case "info":
return "blue";
case "success":
return "green";
case "warning":
return "orange";
case "danger":
return "red";
case "default":
return "gray";
default:
return "blue";
}
}

View File

@ -0,0 +1,28 @@
.icon {
font-size: 24px;
line-height: 1;
width: 20px;
height: 20px;
margin-inline-end: var(--mantine-spacing-md);
margin-top: 4px;
cursor: pointer;
}
.message {
font-size: var(--mantine-font-size-md);
color: var(--mantine-color-default-color);
white-space: nowrap;
word-break: break-word;
overflow-wrap: break-word;
}
/*
@mixin where-light {
color: var(--mantine-color-default-color);
}
@mixin where-dark {
color: var(--mantine-color-default-color);
}
*/

View File

@ -0,0 +1,29 @@
import React, { memo, useCallback, useState } from "react";
import { Slider } from "@mantine/core";
export type ImageWidthProps = {
onChange: (value: number) => void;
value: number;
};
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
const [currentValue, setCurrentValue] = useState(value);
const handleChange = useCallback(
(newValue: number) => {
onChange(newValue);
},
[onChange],
);
return (
<Slider
p={"sm"}
min={10}
value={currentValue}
onChange={setCurrentValue}
onChangeEnd={handleChange}
label={(value) => `${value}%`}
/>
);
});

View File

@ -0,0 +1,151 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ImageMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("image");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignImageLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("left")
.run();
}, [editor]);
const alignImageCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("center")
.run();
}, [editor]);
const alignImageRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("right")
.run();
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageWidth(value)
.run();
},
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align image left">
<ActionIcon
onClick={alignImageLeft}
size="lg"
aria-label="Align image left"
variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image center">
<ActionIcon
onClick={alignImageCenter}
size="lg"
aria-label="Align image center"
variant={
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align image right">
<ActionIcon
onClick={alignImageRight}
size="lg"
aria-label="Align image right"
variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("image")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
)}
</BaseBubbleMenu>
);
}
export default ImageMenu;

View File

@ -0,0 +1,33 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getBackendUrl } from "@/lib/config.ts";
export default function ImageView(props: NodeViewProps) {
const { node, selected } = props;
const { src, width, align } = node.attrs;
const flexJustifyContent = useMemo(() => {
if (align === "center") return "center";
if (align === "right") return "flex-end";
return "flex-start";
}, [align]);
return (
<NodeViewWrapper
style={{
position: "relative",
display: "flex",
justifyContent: flexJustifyContent,
}}
>
<Image
radius="md"
src={getBackendUrl() + src}
fit="contain"
w={width}
className={selected && "ProseMirror-selectednode"}
/>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,24 @@
import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
console.log("dont upload");
return await uploadFile(file, pageId);
} catch (err) {
console.error("failed to upload image", err);
throw err;
}
},
validateFn: (file) => {
if (!file.type.includes("image/")) {
return false;
}
if (file.size / 1024 / 1024 > 20) {
//error("File size too big (max 20MB).");
return false;
}
return true;
},
});

View File

@ -0,0 +1,155 @@
import "katex/dist/katex.min.css";
import katex from "katex";
//import "katex/dist/contrib/mhchem.min.js";
import { useEffect, useRef, useState } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Flex, Popover, Stack, Textarea } from "@mantine/core";
import classes from "./math.module.css";
import { v4 } from "uuid";
import { IconTrashX } from "@tabler/icons-react";
import { useDebouncedValue } from "@mantine/hooks";
export default function MathBlockView(props: NodeViewProps) {
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [debouncedPreview] = useDebouncedValue(preview, 500);
const renderMath = (
katexString: string,
container: HTMLDivElement | null,
) => {
try {
katex.render(katexString, container!, {
displayMode: true,
strict: false,
});
setError(null);
} catch (e) {
console.error(e.message);
setError(e.message);
}
};
useEffect(() => {
renderMath(node.attrs.katex, mathResultContainer.current);
}, [node.attrs.katex]);
useEffect(() => {
if (isEditing) {
renderMath(preview || "", mathPreviewContainer.current);
}
}, [preview, isEditing]);
useEffect(() => {
if (debouncedPreview !== null) {
queueMicrotask(() => {
updateAttributes({ katex: debouncedPreview });
});
}
}, [debouncedPreview]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.katex);
}, [props.selected]);
return (
<Popover
opened={isEditing && editor.isEditable}
trapFocus
position="top"
shadow="md"
width={500}
withArrow={true}
zIndex={101}
id={v4()}
>
<Popover.Target>
<NodeViewWrapper
data-katex="true"
className={[
classes.mathBlock,
props.selected ? classes.selected : "",
error ? classes.error : "",
(isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.katex.trim().length)
? classes.empty
: "",
].join(" ")}
>
<div
style={{
display: isEditing && preview?.length ? undefined : "none",
}}
ref={mathPreviewContainer}
></div>
<div
style={{ display: isEditing ? "none" : undefined }}
ref={mathResultContainer}
></div>
{((isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.katex.trim().length)) && (
<div>Empty equation</div>
)}
{error && <div>Invalid equation</div>}
</NodeViewWrapper>
</Popover.Target>
<Popover.Dropdown>
<Stack>
<Textarea
minRows={4}
maxRows={8}
autosize
ref={textAreaRef}
draggable="false"
value={preview ?? ""}
placeholder={"E = mc^2"}
classNames={{ input: classes.textInput }}
onBlur={(e) => {
e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
return editor.commands.focus(getPos() + node.nodeSize);
}
if (!textAreaRef.current) return;
const { selectionStart, selectionEnd } = textAreaRef.current;
if (
(e.key === "ArrowLeft" || e.key === "ArrowUp") &&
selectionStart === selectionEnd &&
selectionStart === 0
) {
editor.commands.focus(getPos() - 1);
}
if (
(e.key === "ArrowRight" || e.key === "ArrowDown") &&
selectionStart === selectionEnd &&
selectionStart === textAreaRef.current?.value.length
) {
editor.commands.focus(getPos() + node.nodeSize);
}
}}
onChange={(e) => {
setPreview(e.target.value);
}}
></Textarea>
<Flex justify="flex-end" align="flex-end">
<ActionIcon variant="light" color="red">
<IconTrashX size={18} onClick={() => props.deleteNode()} />
</ActionIcon>
</Flex>
</Stack>
</Popover.Dropdown>
</Popover>
);
}

View File

@ -0,0 +1,135 @@
import "katex/dist/katex.min.css";
import katex from "katex";
//import "katex/dist/contrib/mhchem.min.js";
import { useEffect, useRef, useState } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Popover, Textarea } from "@mantine/core";
import classes from "./math.module.css";
import { v4 } from "uuid";
export default function MathInlineView(props: NodeViewProps) {
const { node, updateAttributes, editor, getPos } = props;
const mathResultContainer = useRef<HTMLDivElement>(null);
const mathPreviewContainer = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const renderMath = (
katexString: string,
container: HTMLDivElement | null,
) => {
try {
katex.render(katexString, container);
setError(null);
} catch (e) {
console.error(e);
setError(e.message);
}
};
useEffect(() => {
renderMath(node.attrs.katex, mathResultContainer.current);
}, [node.attrs.katex]);
useEffect(() => {
if (isEditing) {
renderMath(preview || "", mathPreviewContainer.current);
} else if (preview !== null) {
queueMicrotask(() => {
updateAttributes({ katex: preview });
});
}
}, [preview, isEditing]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.katex);
}, [props.selected]);
return (
<>
<Popover
opened={isEditing && editor.isEditable}
trapFocus
position="top"
shadow="md"
width={400}
middlewares={{ flip: true, shift: true, inline: true }}
withArrow={true}
zIndex={101}
id={v4()}
>
<Popover.Target>
<NodeViewWrapper
data-katex="true"
className={[
classes.mathInline,
props.selected ? classes.selected : "",
error ? classes.error : "",
(isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.katex.trim().length)
? classes.empty
: "",
].join(" ")}
>
<div
style={{ display: isEditing ? undefined : "none" }}
ref={mathPreviewContainer}
></div>
<div
style={{ display: isEditing ? "none" : undefined }}
ref={mathResultContainer}
></div>
{((isEditing && !preview?.trim().length) ||
(!isEditing && !node.attrs.katex.trim().length)) && (
<div>Empty equation</div>
)}
{error && <div>Invalid equation</div>}
</NodeViewWrapper>
</Popover.Target>
<Popover.Dropdown p={"xs"}>
<Textarea
minRows={1}
maxRows={5}
autosize
ref={textAreaRef}
draggable={false}
classNames={{ input: classes.textInput }}
value={preview?.trim() ?? ""}
placeholder={"E = mc^2"}
onKeyDown={(e) => {
if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) {
return editor.commands.focus(getPos() + node.nodeSize);
}
if (!textAreaRef.current) return;
const { selectionStart, selectionEnd } = textAreaRef.current;
if (
e.key === "ArrowLeft" &&
selectionStart === selectionEnd &&
selectionStart === 0
) {
editor.commands.focus(getPos());
}
if (
e.key === "ArrowRight" &&
selectionStart === selectionEnd &&
selectionStart === textAreaRef.current.value.length
) {
editor.commands.focus(getPos() + node.nodeSize);
}
}}
onChange={(e) => {
setPreview(e.target.value);
}}
/>
</Popover.Dropdown>
</Popover>
</>
);
}

View File

@ -0,0 +1,61 @@
.mathInline {
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
caret-color: rgb(55, 53, 47);
border-radius: 4px;
transition: background-color 0.2s;
padding: 0 0.25rem;
margin: 0 0.1rem;
&.empty {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&.error {
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
}
.mathBlock {
display: block;
text-align: center;
padding: 0.05rem;
white-space: pre-wrap;
word-break: break-word;
caret-color: rgb(55, 53, 47);
border-radius: 4px;
transition: background-color 0.2s;
margin: 0 0.1rem;
overflow-x: scroll;
.textInput {
width: 400px;
}
& > div {
margin: 1rem 0;
}
&.empty {
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&.error {
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
}

View File

@ -3,7 +3,14 @@ import {
SlashMenuGroupedItemsType,
SlashMenuItemType,
} from "@/features/editor/components/slash-menu/types";
import { Group, Paper, ScrollArea, Text, UnstyledButton } from "@mantine/core";
import {
ActionIcon,
Group,
Paper,
ScrollArea,
Text,
UnstyledButton,
} from "@mantine/core";
import classes from "./slash-menu.module.css";
import clsx from "clsx";
@ -78,7 +85,7 @@ const CommandList = ({
return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<ScrollArea viewportRef={viewportRef} h={350} w={250} scrollbarSize={5}>
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
@ -94,7 +101,13 @@ const CommandList = ({
})}
>
<Group>
<item.icon size={16} />
<ActionIcon
variant="default"
component="div"
aria-label={item.title}
>
<item.icon size={18} />
</ActionIcon>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>

View File

@ -1,155 +1,277 @@
import {
IconBlockquote,
IconCheckbox, IconCode,
IconCaretRightFilled,
IconCheckbox,
IconCode,
IconH1,
IconH2,
IconH3,
IconInfoCircle,
IconList,
IconListNumbers, IconPhoto,
IconListNumbers,
IconMath,
IconMathFunction,
IconMovie,
IconPhoto,
IconTable,
IconTypography,
} from '@tabler/icons-react';
import { CommandProps, SlashMenuGroupedItemsType } from '@/features/editor/components/slash-menu/types';
} from "@tabler/icons-react";
import {
CommandProps,
SlashMenuGroupedItemsType,
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
const CommandGroups: SlashMenuGroupedItemsType = {
basic: [
{
title: 'Text',
description: 'Just start typing with plain text.',
searchTerms: ['p', 'paragraph'],
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: IconTypography,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode('paragraph', 'paragraph')
.toggleNode("paragraph", "paragraph")
.run();
},
},
{
title: 'To-do List',
description: 'Track tasks with a to-do list.',
searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'],
title: "To-do list",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: IconCheckbox,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: 'Heading 1',
description: 'Big section heading.',
searchTerms: ['title', 'big', 'large'],
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: IconH1,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 1 })
.setNode("heading", { level: 1 })
.run();
},
},
{
title: 'Heading 2',
description: 'Medium section heading.',
searchTerms: ['subtitle', 'medium'],
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: IconH2,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 2 })
.setNode("heading", { level: 2 })
.run();
},
},
{
title: 'Heading 3',
description: 'Small section heading.',
searchTerms: ['subtitle', 'small'],
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: IconH3,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode('heading', { level: 3 })
.setNode("heading", { level: 3 })
.run();
},
},
{
title: 'Bullet List',
description: 'Create a simple bullet list.',
searchTerms: ['unordered', 'point'],
title: "Bullet list",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point", "list"],
icon: IconList,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: 'Numbered List',
description: 'Create a list with numbering.',
searchTerms: ['ordered'],
title: "Numbered list",
description: "Create a list with numbering.",
searchTerms: ["numbered", "ordered", "list"],
icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: 'Quote',
description: 'Capture a quote.',
searchTerms: ['blockquote', 'quotes'],
title: "Quote",
description: "Create block quote.",
searchTerms: ["blockquote", "quotes"],
icon: IconBlockquote,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode('paragraph', 'paragraph')
.toggleBlockquote()
.run(),
editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
},
{
title: 'Code',
description: 'Capture a code snippet.',
searchTerms: ['codeblock'],
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: IconCode,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: 'Image',
description: 'Upload an image from your computer.',
searchTerms: ['photo', 'picture', 'media'],
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: IconPhoto,
command: ({ editor, range }: CommandProps) => {
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload image
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
//startImageUpload(file, editor.view, pos);
uploadImageAction(file, editor.view, pos, pageId);
}
};
input.click();
},
},
{
title: "Video",
description: "Upload an video from your computer.",
searchTerms: ["video", "mp4", "media"],
icon: IconMovie,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload video
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadVideoAction(file, editor.view, pos, pageId);
}
};
input.click();
},
},
{
title: "Table",
description: "Insert a table.",
searchTerms: ["table", "rows", "columns"],
icon: IconTable,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: false })
.run(),
},
{
title: "Toggle block",
description: "Insert collapsible block.",
searchTerms: ["collapsible", "block", "toggle", "details", "expand"],
icon: IconCaretRightFilled,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleDetails().run(),
},
{
title: "Callout",
description: "Insert callout notice.",
searchTerms: [
"callout",
"notice",
"panel",
"info",
"warning",
"success",
"error",
"danger",
],
icon: IconInfoCircle,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCallout().run(),
},
{
title: "Math inline",
description: "Insert inline math equation.",
searchTerms: [
"math",
"inline",
"mathinline",
"inlinemath",
"inline math",
"equation",
"katex",
"latex",
"tex",
],
icon: IconMathFunction,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.setMathInline()
.setNodeSelection(range.from)
.run(),
},
{
title: "Math block",
description: "Insert math equation",
searchTerms: [
"math",
"block",
"mathblock",
"block math",
"equation",
"katex",
"latex",
"tex",
],
icon: IconMath,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setMathBlock().run(),
},
],
};
export const getSuggestionItems = ({ query }: { query: string }): SlashMenuGroupedItemsType => {
export const getSuggestionItems = ({
query,
}: {
query: string;
}): SlashMenuGroupedItemsType => {
const search = query.toLowerCase();
const filteredGroups: SlashMenuGroupedItemsType = {};
for (const [group, items] of Object.entries(CommandGroups)) {
const filteredItems = items.filter((item) => {
return item.title.toLowerCase().includes(search)
|| item.description.toLowerCase().includes(search)
|| (item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)));
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
);
});
if (filteredItems.length) {

View File

@ -0,0 +1,110 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { isCellSelection } from "@docmost/editor-ext";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconBoxMargin,
IconColumnRemove,
IconRowRemove,
IconSquareToggle,
} from "@tabler/icons-react";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
const shouldShow = useCallback(
({ view, state, from }: ShouldShowProps) => {
if (!state) {
return false;
}
return isCellSelection(state.selection);
},
[editor],
);
const mergeCells = useCallback(() => {
editor.chain().focus().mergeCells().run();
}, [editor]);
const splitCell = useCallback(() => {
editor.chain().focus().splitCell().run();
}, [editor]);
const deleteColumn = useCallback(() => {
editor.chain().focus().deleteColumn().run();
}, [editor]);
const deleteRow = useCallback(() => {
editor.chain().focus().deleteRow().run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="table-cell-menu"
updateDelay={0}
tippyOptions={{
appendTo: () => {
return appendTo?.current;
},
offset: [0, 15],
zIndex: 99,
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Merge cells">
<ActionIcon
onClick={mergeCells}
variant="default"
size="lg"
aria-label="Merge cells"
>
<IconBoxMargin size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Split cell">
<ActionIcon
onClick={splitCell}
variant="default"
size="lg"
aria-label="Split cell"
>
<IconSquareToggle size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
>
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);
},
);
export default TableCellMenu;

View File

@ -0,0 +1,197 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconColumnInsertLeft,
IconColumnInsertRight,
IconColumnRemove,
IconRowInsertBottom,
IconRowInsertTop,
IconRowRemove,
IconTrashX,
} from "@tabler/icons-react";
import { isCellSelection } from "@docmost/editor-ext";
export const TableMenu = React.memo(
({ editor }: EditorMenuProps): JSX.Element => {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "table";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const addColumnLeft = useCallback(() => {
editor.chain().focus().addColumnBefore().run();
}, [editor]);
const addColumnRight = useCallback(() => {
editor.chain().focus().addColumnAfter().run();
}, [editor]);
const deleteColumn = useCallback(() => {
editor.chain().focus().deleteColumn().run();
}, [editor]);
const addRowAbove = useCallback(() => {
editor.chain().focus().addRowBefore().run();
}, [editor]);
const addRowBelow = useCallback(() => {
editor.chain().focus().addRowAfter().run();
}, [editor]);
const deleteRow = useCallback(() => {
editor.chain().focus().deleteRow().run();
}, [editor]);
const deleteTable = useCallback(() => {
editor.chain().focus().deleteTable().run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="table-menu"
updateDelay={0}
tippyOptions={{
getReferenceClientRect: getReferenceClientRect,
offset: [0, 15],
zIndex: 99,
popperOptions: {
modifiers: [
{
name: "preventOverflow",
enabled: true,
options: {
altAxis: true,
boundary: "clippingParents",
padding: 8,
},
},
{
name: "flip",
enabled: true,
options: {
boundary: editor.options.element,
fallbackPlacements: ["top", "bottom"],
padding: { top: 35, left: 8, right: 8, bottom: -Infinity },
},
},
],
},
}}
shouldShow={shouldShow}
>
<ActionIcon.Group>
<Tooltip position="top" label="Add left column">
<ActionIcon
onClick={addColumnLeft}
variant="default"
size="lg"
aria-label="Add left column"
>
<IconColumnInsertLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add right column">
<ActionIcon
onClick={addColumnRight}
variant="default"
size="lg"
aria-label="Add right column"
>
<IconColumnInsertRight size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete column">
<ActionIcon
onClick={deleteColumn}
variant="default"
size="lg"
aria-label="Delete column"
>
<IconColumnRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row above">
<ActionIcon
onClick={addRowAbove}
variant="default"
size="lg"
aria-label="Add row above"
>
<IconRowInsertTop size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Add row below">
<ActionIcon
onClick={addRowBelow}
variant="default"
size="lg"
aria-label="Add row below"
>
<IconRowInsertBottom size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete row">
<ActionIcon
onClick={deleteRow}
variant="default"
size="lg"
aria-label="Delete row"
>
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Delete table">
<ActionIcon
onClick={deleteTable}
variant="default"
size="lg"
color="red"
aria-label="Delete table"
>
<IconTrashX size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);
},
);
export default TableMenu;

View File

@ -0,0 +1,20 @@
import React from "react";
import { Editor as CoreEditor } from "@tiptap/core";
import { Editor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export interface EditorMenuProps {
editor: Editor;
appendTo?: React.RefObject<any>;
shouldHide?: boolean;
}
export interface ShouldShowProps {
editor?: CoreEditor;
view: EditorView;
state?: EditorState;
oldState?: EditorState;
from?: number;
to?: number;
}

View File

@ -0,0 +1,23 @@
import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
console.error("failed to upload image", err);
throw err;
}
},
validateFn: (file) => {
if (!file.type.includes("video/")) {
return false;
}
if (file.size / 1024 / 1024 > 20) {
return false;
}
return true;
},
});

View File

@ -0,0 +1,151 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
} from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function VideoMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("video");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const alignVideoLeft = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setVideoAlign("left")
.run();
}, [editor]);
const alignVideoCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setVideoAlign("center")
.run();
}, [editor]);
const alignVideoRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setVideoAlign("right")
.run();
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setVideoWidth(value)
.run();
},
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<ActionIcon.Group className="actionIconGroup">
<Tooltip position="top" label="Align video left">
<ActionIcon
onClick={alignVideoLeft}
size="lg"
aria-label="Align video left"
variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video center">
<ActionIcon
onClick={alignVideoCenter}
size="lg"
aria-label="Align video center"
variant={
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label="Align video right">
<ActionIcon
onClick={alignVideoRight}
size="lg"
aria-label="Align video right"
variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("video")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
)}
</BaseBubbleMenu>
);
}
export default VideoMenu;

View File

@ -0,0 +1,32 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useMemo } from "react";
import { getBackendUrl } from "@/lib/config.ts";
export default function VideoView(props: NodeViewProps) {
const { node, selected } = props;
const { src, width, align } = node.attrs;
const flexJustifyContent = useMemo(() => {
if (align === "center") return "center";
if (align === "right") return "flex-end";
return "flex-start";
}, [align]);
return (
<NodeViewWrapper
style={{
position: "relative",
display: "flex",
justifyContent: flexJustifyContent,
}}
>
<video
preload="metadata"
width={width}
controls
src={getBackendUrl() + src}
className={selected && "ProseMirror-selectednode"}
/>
</NodeViewWrapper>
);
}