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

@ -19,8 +19,9 @@
"@mantine/modals": "^7.10.1",
"@mantine/notifications": "^7.10.1",
"@mantine/spotlight": "^7.10.1",
"@tabler/icons-react": "^3.5.0",
"@tabler/icons-react": "^3.6.0",
"@tanstack/react-query": "^5.40.0",
"@tiptap/extension-code-block-lowlight": "^2.4.0",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
@ -29,11 +30,14 @@
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "^0.16.10",
"lowlight": "^3.1.0",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0",
"react-router-dom": "^6.23.1",
"socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7",
@ -43,6 +47,7 @@
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.35.6",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

View File

@ -24,6 +24,7 @@ import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
export default function App() {
const [, setSocket] = useAtom(socketAtom);
@ -70,7 +71,16 @@ export default function App() {
<Route path={"/home"} element={<Home />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route path={"/s/:spaceSlug/p/:pageSlug"} element={<Page />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>Failed to load page. An error occurred.</>}
>
<Page />
</ErrorBoundary>
}
/>
<Route path={"/settings"}>
<Route path={"account/profile"} element={<AccountSettings />} />

View File

@ -1,12 +1,4 @@
import {
Text,
Group,
Stack,
UnstyledButton,
Divider,
Badge,
} from "@mantine/core";
import classes from "../../features/home/components/home.module.css";
import { Text, Group, UnstyledButton, Badge, Table } from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -30,17 +22,15 @@ export default function RecentChanges({ spaceId }: Props) {
}
return pages && pages.items.length > 0 ? (
<div>
{pages.items.map((page) => (
<div key={page.id}>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
className={classes.page}
p="xs"
>
<Group wrap="nowrap">
<Stack gap="xs" style={{ flex: 1 }}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Tbody>
{pages.items.map((page) => (
<Table.Tr key={page.id}>
<Table.Td>
<UnstyledButton
component={Link}
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || <IconFileDescription size={18} />}
@ -48,28 +38,30 @@ export default function RecentChanges({ spaceId }: Props) {
{page.title || "Untitled"}
</Text>
</Group>
</Stack>
{!spaceId && (
</UnstyledButton>
</Table.Td>
{!spaceId && (
<Table.Td>
<Badge
color="blue"
variant="light"
component={Link}
to={getSpaceUrl(page.space.slug)}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
>
{page.space.name}
{page?.space.name}
</Badge>
)}
</Table.Td>
)}
<Table.Td>
<Text c="dimmed" size="xs" fw={500}>
{formattedDate(page.updatedAt)}
</Text>
</Group>
</UnstyledButton>
<Divider />
</div>
))}
</div>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="md" ta="center">
No records to show

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>
);
}

View File

@ -0,0 +1,371 @@
// MIT - source: https://github.com/NiclasDev63/tiptap-extension-global-drag-handle
import { Extension } from "@tiptap/core";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== "none") {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width,
};
}
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
".tableWrapper",
].join(", "),
),
);
}
function nodePosAtDOM(
node: Element,
view: EditorView,
options: GlobalDragHandleOptions,
) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(
options: GlobalDragHandleOptions & { pluginKey: string },
) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(
view.state.doc,
nodePos.before(),
);
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos &&
draggedNodePos <= nodeSelection.$to.pos
);
}
if (
!differentNodeSelected &&
diff !== 0 &&
!(view.state.selection instanceof NodeSelection)
) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(
view.state.doc,
draggedNodePos,
endSelection.$to.pos,
);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
const nodeSelection = NodeSelection.create(
view.state.doc,
draggedNodePos,
);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem"
) {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hide");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hide");
}
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
let scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
}
}
dragHandleElement.addEventListener("drag", onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
dragHandleElement?.removeEventListener(
"dragstart",
onDragHandleDragStart,
);
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
const notDragging = node?.closest(".not-draggable");
//const isNodeInTable = node?.closest("td") || node?.closest("th");
if (
!(node instanceof Element) ||
node.matches("ul, ol") ||
notDragging ||
node.matches(".tableWrapper")
) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight)
? parseInt(compStyle.fontSize) * 1.2
: parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList =
resolvedPos.parent.type.name === "listItem";
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill(
{},
view.state.schema.text(text),
);
const listItem = view.state.schema.nodes.listItem?.createAndFill(
{},
paragraph,
);
const newList = view.state.schema.nodes.orderedList?.createAndFill(
null,
listItem,
);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
const GlobalDragHandle = Extension.create({
name: "globalDragHandle",
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: "globalDragHandle",
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
}),
];
},
});
export default GlobalDragHandle;

View File

@ -11,17 +11,41 @@ import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Comment, TrailingNode } from "@docmost/editor-ext";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import {
Comment,
Details,
DetailsContent,
DetailsSummary,
MathBlock,
MathInline,
Table,
TableHeader,
TableCell,
TableRow,
TrailingNode,
TiptapImage,
Callout,
TiptapVideo,
} from "@docmost/editor-ext";
import {
randomElement,
userColors,
} from "@/features/editor/extensions/utils.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import { Youtube } from "@tiptap/extension-youtube";
import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx";
const lowlight = createLowlight(common);
export const mainExtensions = [
StarterKit.configure({
@ -30,6 +54,7 @@ export const mainExtensions = [
width: 3,
color: "#70CFF8",
},
codeBlock: false,
}),
Placeholder.configure({
placeholder: 'Enter "/" for commands',
@ -57,6 +82,36 @@ export const mainExtensions = [
class: "comment-mark",
},
}),
Table,
TableRow,
TableCell,
TableHeader,
MathInline.configure({
view: MathInlineView,
}),
MathBlock.configure({
view: MathBlockView,
}),
Details,
DetailsSummary,
DetailsContent,
Youtube.configure({
controls: true,
nocookie: true,
}),
TiptapImage.configure({
view: ImageView,
allowBase64: false,
}),
TiptapVideo.configure({
view: VideoView,
}),
Callout.configure({
view: CalloutView,
}),
CodeBlockLowlight.configure({
lowlight,
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -1,5 +1,11 @@
import "@/features/editor/styles/index.css";
import React, { useEffect, useLayoutEffect, useMemo, useState } from "react";
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import { HocuspocusProvider } from "@hocuspocus/provider";
@ -21,6 +27,14 @@ import {
import CommentDialog from "@/features/comment/components/comment-dialog";
import EditorSkeleton from "@/features/editor/components/editor-skeleton";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { handleMediaDrop, handleMediaPaste } from "@docmost/editor-ext";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
interface PageEditorProps {
pageId: string;
@ -39,6 +53,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const documentName = `page.${pageId}`;
const menuContainerRef = useRef(null);
const localProvider = useMemo(() => {
const provider = new IndexeddbPersistence(documentName, ydoc);
@ -97,11 +112,20 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
}
},
},
handlePaste: (view, event) => {
handleMediaPaste(view, event, uploadImageAction, pageId);
handleMediaPaste(view, event, uploadVideoAction, pageId);
},
handleDrop: (view, event, _slice, moved) => {
handleMediaDrop(view, event, moved, uploadImageAction, pageId);
handleMediaDrop(view, event, moved, uploadVideoAction, pageId);
},
},
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setEditor(editor);
editor.storage.pageId = pageId;
}
},
},
@ -139,12 +163,17 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
return isSynced ? (
<div>
{isSynced && (
<div>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
{editor && editor.isEditable && (
<div>
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
</div>
)}

View File

@ -0,0 +1,89 @@
.ProseMirror {
pre {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
border-radius: var(--mantine-radius-default);
@mixin light {
background-color: var(--mantine-color-gray-0);
color: var(--mantine-color-gray-9);
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-dark-1);
}
code {
color: inherit;
padding: 0;
background: none;
font-size: inherit;
}
/* Code styling */
.hljs-comment,
.hljs-quote {
color: light-dark(
var(--mantine-color-gray-5),
var(--mantine-color-dark-2)
);
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-5));
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: light-dark(
var(--mantine-color-blue-7),
var(--mantine-color-cyan-5)
);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: light-dark(var(--mantine-color-red-7), var(--mantine-color-red-5));
}
.hljs-title,
.hljs-section {
color: light-dark(
var(--mantine-color-pink-7),
var(--mantine-color-yellow-5)
);
}
.hljs-keyword,
.hljs-selector-tag {
color: light-dark(
var(--mantine-color-violet-7),
var(--mantine-color-violet-3)
);
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
}

View File

@ -1,110 +1,133 @@
.ProseMirror {
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-default-color), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
font-weight: 400;
width: 100%;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
color: light-dark(
var(--mantine-color-default-color),
var(--mantine-color-dark-0)
);
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl);
font-weight: 400;
width: 100%;
> * + * {
margin-top: 0.75em;
{
margin-top: 0.75em;
}
&:focus {
outline: none;
}
ul,
ol {
padding: 0 1rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}
code {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-8)
);
color: #616161;
}
a {
color: light-dark(#207af1, #587da9);
font-weight: bold;
text-decoration: none;
cursor: pointer;
}
blockquote {
padding-left: 25px;
padding-right: 25px;
border-left: 2px solid var(--mantine-color-gray-6);
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-8)
);
margin: 0;
}
hr {
border: none;
border-top: 2px solid #ced4da;
margin: 2rem 0;
&:hover {
cursor: pointer;
}
}
&:focus {
outline: none;
hr.ProseMirror-selectednode {
border-top: 1px solid #68cef8;
}
.ProseMirror-selectednode {
outline: 2px solid #70cff8;
}
.node-mathInline {
.katex-display {
margin: 0;
}
}
ul,
ol {
padding: 0 1rem;
margin-top: .25rem;
margin-bottom: .25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
}
code {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
color: #616161;
}
pre {
padding: var(--mantine-spacing-xs);
margin: var(--mantine-spacing-md) 0;
font-family: var(--mantine-font-family-monospace);
border-radius: var(--mantine-radius-sm);
@mixin light {
background-color: var(--mantine-color-gray-0);
color: var(--mantine-color-black);
.react-renderer {
&.node-callout {
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
div[style*="white-space: inherit;"] {
> :first-child {
margin: 0;
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-white);
}
code {
color: inherit;
padding: 0;
background: none;
font-size: inherit;
}
}
img {
max-width: 100%;
height: auto;
}
a {
color: light-dark(#207af1, #587da9);
font-weight: bold;
text-decoration: none;
cursor: pointer;
}
blockquote {
padding-left: 25px;
border-left: 2px solid var(--mantine-color-gray-6);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
margin: 0;
}
hr {
border: none;
border-top: 2px solid #ced4da;
margin: 2rem 0;
&:hover {
cursor: pointer;
}
}
hr.ProseMirror-selectednode {
border-top: 1px solid #68CEF8;
}
.ProseMirror-selectednode {
outline: 2px solid #70CFF8;
}
}
}
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
cursor: ew-resize;
cursor: col-resize;
}
.comment-mark {
background: rgba(255, 215, 0, .14);
border-bottom: 2px solid rgb(166, 158, 12);
background: rgba(255, 215, 0, 0.14);
border-bottom: 2px solid rgb(166, 158, 12);
}
.ProseMirror-icon {
display: inline-block;
width: 1em;
height: 1em;
mask-image: var(--svg);
mask-repeat: no-repeat;
mask-size: 100% 100%;
background-color: currentColor;
& -open {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M10 3v2H5v14h14v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7.586 2H13V3h8v8h-2V6.414l-7 7L10.586 12z'/%3E%3C/svg%3E");
}
&-right-line {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12.172 12L9.343 9.173l1.415-1.414L15 12l-4.242 4.242l-1.415-1.414z'/%3E%3C/svg%3E");
}
}
.actionIconGroup {
background: var(--mantine-color-body);
}

View File

@ -0,0 +1,77 @@
.ProseMirror {
[data-type="details"] {
display: flex;
padding: 0.5em;
border-radius: 4px;
border: 1px solid transparent;
transition: border 0.3s;
width: 100%;
&:hover {
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
[data-type="detailsSummary"] {}
}
[data-type="detailsButton"] {
display: flex;
cursor: pointer;
border: none;
padding: 0;
background: transparent;
.ProseMirror-icon {
width: 2em;
height: 2em;
transform: rotateZ(0deg);
transition: transform 0.3s;
}
> div {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover,
&.active {
color: light-dark(
var(--mantine-color-gray-8),
var(--mantine-color-gray-0)
);
background: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-gray-8)
);
}
}
}
[data-type="detailsContainer"] {
flex: 1;
margin-left: 0.2em;
overflow-x: hidden;
word-break: break-word;
overflow-wrap: break-word;
[data-type="detailsContent"] {
display: none;
}
}
&[open] {
[data-type="detailsButton"] {
.ProseMirror-icon {
transform: rotateZ(90deg);
}
}
[data-type="detailsContainer"] {
[data-type="detailsContent"] {
display: block;
}
}
}
}
}

View File

@ -1,9 +1,9 @@
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
/*.ProseMirror:not(.dragging) .ProseMirror-selectednode {
outline: none !important;
background-color: rgba(150, 170, 220, 0.2);
transition: background-color 0.2s;
box-shadow: none;
}
}*/
.drag-handle {
position: fixed;

View File

@ -1,5 +1,12 @@
@import './core';
@import './collaboration';
@import './task-list';
@import './placeholder';
@import './drag-handle';
@import "./core.css";
@import "./collaboration.css";
@import "./task-list.css";
@import "./placeholder.css";
@import "./drag-handle.css";
@import "./details.css";
@import "./table.css";
@import "./youtube.css";
@import "./media.css";
@import "./code.css";

View File

@ -0,0 +1,13 @@
.ProseMirror {
img {
max-width: 100%;
height: auto;
}
.node-image, .node-video {
&.ProseMirror-selectednode {
outline: none;
}
}
}

View File

@ -0,0 +1,73 @@
.tableWrapper {
margin-top: 3rem;
margin-bottom: 3rem;
overflow-x: auto;
& table {
overflow-x: hidden;
}
}
.ProseMirror {
table {
border-collapse: collapse;
margin: 0;
table-layout: fixed;
width: 100%;
td,
th {
border: 1px solid #ced4da;
box-sizing: border-box;
min-width: 1em;
padding: 3px 5px;
position: relative;
vertical-align: top;
&:first-of-type:not(a) {
margin-top: 0;
}
p {
margin: 0;
& + p {
margin-top: 0.75rem;
}
}
}
th {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: left;
}
.column-resize-handle {
background-color: #adf;
bottom: -2px;
position: absolute;
right: -2px;
pointer-events: none;
top: 0;
width: 4px;
}
.selectedCell:after {
background: rgba(200, 200, 255, 0.4);
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
}
}

View File

@ -0,0 +1,21 @@
.ProseMirror {
div[data-youtube-video] {
cursor: move;
iframe {
display: block;
outline: 0px solid transparent;
border-radius: var(--mantine-radius-md);
width: 100%;
}
&.ProseMirror-selectednode iframe {
outline: 1px solid var(--mantine-color-blue-6);
transition: outline 0.15s;
}
&.ProseMirror-selectednode {
background-color: transparent;
}
}
}

View File

@ -5,7 +5,7 @@ import {
IPageInput,
SidebarPagesParams,
} from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
import { IAttachment, IPagination } from "@/lib/types.ts";
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);
@ -52,3 +52,19 @@ export async function getRecentChanges(
const req = await api.post("/pages/recent", { spaceId });
return req.data;
}
export async function uploadFile(file: File, pageId: string) {
const formData = new FormData();
formData.append("pageId", pageId);
formData.append("file", file);
// should be file endpoint
const req = await api.post<IAttachment>("/files/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
// console.log("req", req);
return req;
}

View File

@ -32,3 +32,20 @@ export type IPagination<T> = {
items: T[];
meta: IPaginationMeta;
};
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

View File

@ -3,6 +3,7 @@ import { useEffect } from "react";
import { usePageQuery } from "@/features/page/queries/page-query";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
export default function PageRedirect() {
const { pageSlug } = useParams();
@ -20,6 +21,10 @@ export default function PageRedirect() {
}
}, [page]);
if (isError) {
return <Error404 />;
}
if (pageIsLoading) {
return <></>;
}

View File

@ -42,7 +42,7 @@ export default function Page() {
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${page.title || "untitled"}`}</title>
<title>{`${page?.icon || ""} ${page?.title || "untitled"}`}</title>
</Helmet>
<PageHeader