mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 23:51:12 +10:00
editor improvements
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
This commit is contained in:
@ -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",
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
371
apps/client/src/features/editor/extensions/drag-handle.ts
Normal file
371
apps/client/src/features/editor/extensions/drag-handle.ts
Normal 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;
|
||||
@ -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[];
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
89
apps/client/src/features/editor/styles/code.css
Normal file
89
apps/client/src/features/editor/styles/code.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
77
apps/client/src/features/editor/styles/details.css
Normal file
77
apps/client/src/features/editor/styles/details.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
|
||||
13
apps/client/src/features/editor/styles/media.css
Normal file
13
apps/client/src/features/editor/styles/media.css
Normal file
@ -0,0 +1,13 @@
|
||||
.ProseMirror {
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.node-image, .node-video {
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
apps/client/src/features/editor/styles/table.css
Normal file
73
apps/client/src/features/editor/styles/table.css
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
apps/client/src/features/editor/styles/youtube.css
Normal file
21
apps/client/src/features/editor/styles/youtube.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 <></>;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user