diff --git a/apps/client/package.json b/apps/client/package.json index 6a24ae80..59233390 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 307e60f5..7b85715a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> - } /> + Failed to load page. An error occurred.} + > + + + } + /> } /> diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index c7da7f46..8dedb261 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -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 ? ( -
- {pages.items.map((page) => ( -
- - - + + + {pages.items.map((page) => ( + + + {page.icon || } @@ -48,28 +38,30 @@ export default function RecentChanges({ spaceId }: Props) { {page.title || "Untitled"} - - - {!spaceId && ( + + + {!spaceId && ( + - {page.space.name} + {page?.space.name} - )} - + + )} + {formattedDate(page.updatedAt)} - - - - - ))} - + + + ))} + +
) : ( No records to show diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index e93dbfb1..9cb16433 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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 = (props) => { editor.isActive("image") || empty || isNodeSelection(selection) || + isCellSelection(selection) || showCommentPopupRef?.current ) { return false; diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx new file mode 100644 index 00000000..e80621b4 --- /dev/null +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -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 ( + + + + setCalloutType("info")} + size="lg" + aria-label="Info" + variant={ + editor.isActive("callout", { type: "info" }) ? "light" : "default" + } + > + + + + + + setCalloutType("success")} + size="lg" + aria-label="Success" + variant={ + editor.isActive("callout", { type: "success" }) + ? "light" + : "default" + } + > + + + + + + setCalloutType("warning")} + size="lg" + aria-label="Warning" + variant={ + editor.isActive("callout", { type: "warning" }) + ? "light" + : "default" + } + > + + + + + + setCalloutType("danger")} + size="lg" + aria-label="Danger" + variant={ + editor.isActive("callout", { type: "danger" }) + ? "light" + : "default" + } + > + + + + + + ); +} + +export default CalloutMenu; diff --git a/apps/client/src/features/editor/components/callout/callout-view.tsx b/apps/client/src/features/editor/components/callout/callout-view.tsx new file mode 100644 index 00000000..ee110673 --- /dev/null +++ b/apps/client/src/features/editor/components/callout/callout-view.tsx @@ -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 ( + + + + + + ); +} + +function getCalloutIcon(type: CalloutType) { + switch (type) { + case "info": + return ; + case "success": + return ; + case "warning": + return ; + case "danger": + return ; + default: + return ; + } +} + +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"; + } +} diff --git a/apps/client/src/features/editor/components/callout/callout.module.css b/apps/client/src/features/editor/components/callout/callout.module.css new file mode 100644 index 00000000..2839b426 --- /dev/null +++ b/apps/client/src/features/editor/components/callout/callout.module.css @@ -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); + } +*/ diff --git a/apps/client/src/features/editor/components/common/node-width-resize.tsx b/apps/client/src/features/editor/components/common/node-width-resize.tsx new file mode 100644 index 00000000..ca4e8dcc --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-width-resize.tsx @@ -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 ( + `${value}%`} + /> + ); +}); diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx new file mode 100644 index 00000000..35985993 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + {editor.getAttributes("image")?.width && ( + + )} + + ); +} + +export default ImageMenu; diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx new file mode 100644 index 00000000..6d4051a2 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -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 ( + + + + ); +} diff --git a/apps/client/src/features/editor/components/image/upload-image-action.tsx b/apps/client/src/features/editor/components/image/upload-image-action.tsx new file mode 100644 index 00000000..d0a4e1f4 --- /dev/null +++ b/apps/client/src/features/editor/components/image/upload-image-action.tsx @@ -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 => { + 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; + }, +}); diff --git a/apps/client/src/features/editor/components/math/math-block.tsx b/apps/client/src/features/editor/components/math/math-block.tsx new file mode 100644 index 00000000..456b9f39 --- /dev/null +++ b/apps/client/src/features/editor/components/math/math-block.tsx @@ -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(null); + const mathPreviewContainer = useRef(null); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + const textAreaRef = useRef(null); + const [isEditing, setIsEditing] = useState(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 ( + + + +
+
+ {((isEditing && !preview?.trim().length) || + (!isEditing && !node.attrs.katex.trim().length)) && ( +
Empty equation
+ )} + {error &&
Invalid equation
} +
+
+ + + + + + + props.deleteNode()} /> + + + + +
+ ); +} diff --git a/apps/client/src/features/editor/components/math/math-inline.tsx b/apps/client/src/features/editor/components/math/math-inline.tsx new file mode 100644 index 00000000..4904831a --- /dev/null +++ b/apps/client/src/features/editor/components/math/math-inline.tsx @@ -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(null); + const mathPreviewContainer = useRef(null); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(null); + const textAreaRef = useRef(null); + const [isEditing, setIsEditing] = useState(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 ( + <> + + + +
+
+ {((isEditing && !preview?.trim().length) || + (!isEditing && !node.attrs.katex.trim().length)) && ( +
Empty equation
+ )} + {error &&
Invalid equation
} +
+
+ +