mirror of
https://github.com/docmost/docmost.git
synced 2025-11-19 06:21:10 +10:00
editor improvements
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
This commit is contained in:
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
*/
|
||||
@ -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}%`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
151
apps/client/src/features/editor/components/image/image-menu.tsx
Normal file
151
apps/client/src/features/editor/components/image/image-menu.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
155
apps/client/src/features/editor/components/math/math-block.tsx
Normal file
155
apps/client/src/features/editor/components/math/math-block.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
apps/client/src/features/editor/components/math/math-inline.tsx
Normal file
135
apps/client/src/features/editor/components/math/math-inline.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
197
apps/client/src/features/editor/components/table/table-menu.tsx
Normal file
197
apps/client/src/features/editor/components/table/table-menu.tsx
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
151
apps/client/src/features/editor/components/video/video-menu.tsx
Normal file
151
apps/client/src/features/editor/components/video/video-menu.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user