mirror of
https://github.com/docmost/docmost.git
synced 2025-11-11 09:12:05 +10:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e2b23c97e | |||
| dc3ce27762 | |||
| 8af2d4e8cf | |||
| 73ddec4ca7 | |||
| 2b9765fb35 | |||
| 7fdd355cc3 | |||
| 6c6b47599a | |||
| 7e6a71fa2d | |||
| 1141796f24 | |||
| 11dbc079be | |||
| 87b99f8646 | |||
| 38e9eef2dc | |||
| 77b541ec71 | |||
| 7dc37b933f | |||
| 7e80797e3f | |||
| 17475bf123 | |||
| 4433d5174d | |||
| c810d0b314 | |||
| 463480ae67 | |||
| 2449d69fab | |||
| e0d74fcb0e | |||
| 4967849e3a | |||
| 0a447e91bb |
@ -19,6 +19,7 @@ AWS_S3_SECRET_ACCESS_KEY=
|
||||
AWS_S3_REGION=
|
||||
AWS_S3_BUCKET=
|
||||
AWS_S3_ENDPOINT=
|
||||
AWS_S3_FORCE_PATH_STYLE=
|
||||
|
||||
# options: smtp | postmark
|
||||
MAIL_DRIVER=smtp
|
||||
@ -30,6 +31,7 @@ SMTP_HOST=127.0.0.1
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
|
||||
# Postmark driver config
|
||||
POSTMARK_TOKEN=
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
FROM node:21-alpine AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.2.8",
|
||||
"version": "0.3.1",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -13,58 +13,61 @@
|
||||
"@casl/react": "^4.0.0",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@mantine/core": "^7.11.0",
|
||||
"@mantine/form": "^7.11.0",
|
||||
"@mantine/hooks": "^7.11.0",
|
||||
"@mantine/modals": "^7.11.0",
|
||||
"@mantine/notifications": "^7.11.0",
|
||||
"@mantine/spotlight": "^7.11.0",
|
||||
"@tabler/icons-react": "^3.7.0",
|
||||
"@tanstack/react-query": "^5.48.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.4.0",
|
||||
"axios": "^1.7.2",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@mantine/core": "^7.12.2",
|
||||
"@mantine/form": "^7.12.2",
|
||||
"@mantine/hooks": "^7.12.2",
|
||||
"@mantine/modals": "^7.12.2",
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@tanstack/react-query": "^5.53.2",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jotai": "^2.8.3",
|
||||
"jotai": "^2.9.3",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "^0.16.10",
|
||||
"katex": "^0.16.11",
|
||||
"lowlight": "^3.1.0",
|
||||
"mermaid": "^11.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "^3.4.0",
|
||||
"react-clear-modal": "^2.0.9",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "^0.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-moveable": "^0.56.0",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.10",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.12",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.47.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.53.0",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/node": "22.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.5.0",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"optics-ts": "^2.4.1",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-preset-mantine": "^1.15.0",
|
||||
"postcss": "^8.4.43",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.3.2",
|
||||
"typescript": "^5.5.2",
|
||||
"vite": "^5.3.1"
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
20
apps/client/src/components/icons/icon-drawio.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconDrawio({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#F08705"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconDrawio;
|
||||
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
20
apps/client/src/components/icons/icon-excalidraw.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconExcalidraw({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#6965DB"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconExcalidraw;
|
||||
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
20
apps/client/src/components/icons/icon-mermaid.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { rem } from "@mantine/core";
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
}
|
||||
|
||||
function IconMermaid({ size }: Props) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#FF3670"
|
||||
viewBox="0 0 24 24"
|
||||
style={{ width: rem(size), height: rem(size) }}
|
||||
>
|
||||
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconMermaid;
|
||||
@ -57,6 +57,8 @@ export function AppHeader() {
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
Docmost
|
||||
</Text>
|
||||
|
||||
@ -93,6 +93,18 @@ export default function SettingsSidebar() {
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
<div className={classes.version}>
|
||||
<Text
|
||||
className={classes.version}
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="https://github.com/docmost/docmost/releases"
|
||||
target="_blank"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -57,3 +57,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version {
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import data from "@emoji-mart/data";
|
||||
import Picker from "@emoji-mart/react";
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Popover,
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Picker = React.lazy(() => import('@emoji-mart/react'));
|
||||
|
||||
export interface EmojiPickerInterface {
|
||||
onEmojiSelect: (emoji: any) => void;
|
||||
@ -48,23 +49,25 @@ function EmojiPicker({
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }}>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Picker
|
||||
data={async () => (await import('@emoji-mart/data')).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
perLine={8}
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
bottom: '1rem',
|
||||
right: '1rem',
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap="xl"
|
||||
style={{ cursor: "pointer" }}
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{selected || hovered ? (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { handleAttachmentUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export const uploadAttachmentAction = handleAttachmentUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
try {
|
||||
return await uploadFile(file, pageId);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
validateFn: (file) => {
|
||||
if (file.type.includes("image/") || file.type.includes("video/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,98 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
||||
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
|
||||
import classes from './code-block.module.css';
|
||||
import React from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const MermaidView = React.lazy(
|
||||
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
|
||||
);
|
||||
|
||||
export default function CodeBlockView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, extension, editor, getPos } = props;
|
||||
const { language } = node.attrs;
|
||||
const [languageValue, setLanguageValue] = useState<string | null>(
|
||||
language || null
|
||||
);
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSelection = () => {
|
||||
const { state } = editor;
|
||||
const { from, to } = state.selection;
|
||||
// Check if the selection intersects with the node's range
|
||||
const isNodeSelected =
|
||||
(from >= getPos() && from < getPos() + node.nodeSize) ||
|
||||
(to > getPos() && to <= getPos() + node.nodeSize);
|
||||
setIsSelected(isNodeSelected);
|
||||
};
|
||||
|
||||
editor.on('selectionUpdate', updateSelection);
|
||||
return () => {
|
||||
editor.off('selectionUpdate', updateSelection);
|
||||
};
|
||||
}, [editor, getPos(), node.nodeSize]);
|
||||
|
||||
function changeLanguage(language: string) {
|
||||
setLanguageValue(language);
|
||||
updateAttributes({
|
||||
language: language,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="codeBlock">
|
||||
<Group justify="flex-end" contentEditable={false}>
|
||||
<Select
|
||||
placeholder="auto"
|
||||
checkIconPosition="right"
|
||||
data={extension.options.lowlight.listLanguages()}
|
||||
value={languageValue}
|
||||
onChange={changeLanguage}
|
||||
searchable
|
||||
style={{ maxWidth: '130px' }}
|
||||
classNames={{ input: classes.selectInput }}
|
||||
disabled={!editor.isEditable}
|
||||
/>
|
||||
|
||||
<CopyButton value={node?.textContent} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? 'Copied' : 'Copy'}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<pre
|
||||
spellCheck="false"
|
||||
hidden={
|
||||
((language === 'mermaid' && !editor.isEditable) ||
|
||||
(language === 'mermaid' && !isSelected)) &&
|
||||
node.textContent.length > 0
|
||||
}
|
||||
>
|
||||
<NodeViewContent as="code" className={`language-${language}`} />
|
||||
</pre>
|
||||
|
||||
{language === 'mermaid' && (
|
||||
<Suspense fallback={null}>
|
||||
<MermaidView props={props} />
|
||||
</Suspense>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
.selectInput {
|
||||
height: 25px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.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));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import mermaid from "mermaid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import classes from "./code-block.module.css";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
interface MermaidViewProps {
|
||||
props: NodeViewProps;
|
||||
}
|
||||
|
||||
export default function MermaidView({ props }: MermaidViewProps) {
|
||||
const { node } = props;
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const id = `mermaid-${uuidv4()}`;
|
||||
|
||||
if (node.textContent.length > 0) {
|
||||
mermaid
|
||||
.render(id, node.textContent)
|
||||
.then((item) => {
|
||||
setPreview(item.svg);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (props.editor.isEditable) {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
|
||||
);
|
||||
} else {
|
||||
setPreview(
|
||||
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [node.textContent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.mermaid}
|
||||
contentEditable={false}
|
||||
dangerouslySetInnerHTML={{ __html: preview }}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
|
||||
export const handleFilePaste = (
|
||||
view: EditorView,
|
||||
@ -15,6 +16,7 @@ export const handleFilePaste = (
|
||||
if (file) {
|
||||
uploadImageAction(file, view, pos, pageId);
|
||||
uploadVideoAction(file, view, pos, pageId);
|
||||
uploadAttachmentAction(file, view, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -38,6 +40,7 @@ export const handleFileDrop = (
|
||||
if (file) {
|
||||
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,28 +1,34 @@
|
||||
import React, { memo, useCallback, useState } from "react";
|
||||
import { Slider } from "@mantine/core";
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Slider } from '@mantine/core';
|
||||
|
||||
export type ImageWidthProps = {
|
||||
onChange: (value: number) => void;
|
||||
value: number;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
|
||||
export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: number) => {
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
p={"sm"}
|
||||
p={'sm'}
|
||||
min={10}
|
||||
value={currentValue}
|
||||
onChange={setCurrentValue}
|
||||
onChangeEnd={handleChange}
|
||||
w={width || 100}
|
||||
label={(value) => `${value}%`}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
} from '@tiptap/react';
|
||||
import { 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 { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||
|
||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === 'drawio';
|
||||
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 onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes('drawio', { width: `${value}%` });
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: 'popper',
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editor.getAttributes('drawio')?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={parseInt(editor.getAttributes('drawio').width)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawioMenu;
|
||||
@ -0,0 +1,173 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { getFileUrl } from '@/lib/config.ts';
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
EventExit,
|
||||
EventSave,
|
||||
} from 'react-drawio';
|
||||
import { IAttachment } from '@/lib/types';
|
||||
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
|
||||
export default function DrawioView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const [initialXML, setInitialXML] = useState<string>('');
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const blob = await request.blob();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
let base64data = (reader.result || '') as string;
|
||||
setInitialXML(base64data);
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
|
||||
const fileName = 'diagram.drawio.svg';
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||
}
|
||||
|
||||
updateAttributes({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: 'hidden' }}>
|
||||
<Modal.Body>
|
||||
<div style={{ height: '100vh' }}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
xml={initialXML}
|
||||
urlParameters={{
|
||||
ui: 'kennedy',
|
||||
spin: true,
|
||||
libraries: true,
|
||||
saveAndExit: true,
|
||||
noSaveBtn: true,
|
||||
}}
|
||||
onSave={(data: EventSave) => {
|
||||
// If the save is triggered by another event, then do nothing
|
||||
if (data.parentEvent !== 'save') {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
// If the exit is triggered by another event, then do nothing
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
Double-click to edit drawio diagram
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
} from '@tiptap/react';
|
||||
import { 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 { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||
|
||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
|
||||
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 onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: 'popper',
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editor.getAttributes('excalidraw')?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={parseInt(editor.getAttributes('excalidraw').width)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExcalidrawMenu;
|
||||
@ -0,0 +1,212 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { uploadFile } from '@/features/page/services/page-service.ts';
|
||||
import { svgStringToFile } from '@/lib';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { getFileUrl } from '@/lib/config.ts';
|
||||
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
|
||||
import { IAttachment } from '@/lib/types';
|
||||
import ReactClearModal from 'react-clear-modal';
|
||||
import clsx from 'clsx';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import { lazy } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
import('@excalidraw/excalidraw').then((module) => ({
|
||||
default: module.Excalidraw,
|
||||
}))
|
||||
);
|
||||
|
||||
export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const { loadFromBlob } = await import('@excalidraw/excalidraw');
|
||||
|
||||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
setExcalidrawData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { exportToSvg } = await import('@excalidraw/excalidraw');
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: computedColorScheme == 'light' ? false : true,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
'https://unpkg.com/@excalidraw/excalidraw@latest'
|
||||
);
|
||||
|
||||
const fileName = 'diagram.excalidraw.svg';
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||
}
|
||||
|
||||
updateAttributes({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<ReactClearModal
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
padding: 0,
|
||||
width: '90vw',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSave} size={'compact-sm'}>
|
||||
Save & Exit
|
||||
</Button>
|
||||
<Button onClick={close} color="red" size={'compact-sm'}>
|
||||
Exit
|
||||
</Button>
|
||||
</Group>
|
||||
<div style={{ height: '90vh' }}>
|
||||
<Suspense fallback={null}>
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? 'ProseMirror-selectednode' : '',
|
||||
'alignCenter'
|
||||
)}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card
|
||||
radius="md"
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
p="xs"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
withBorder
|
||||
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<ActionIcon variant="transparent" color="gray">
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
Double-click to edit excalidraw diagram
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,16 @@
|
||||
import { handleImageUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export const uploadImageAction = handleImageUpload({
|
||||
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||
try {
|
||||
return await uploadFile(file, pageId);
|
||||
} catch (err) {
|
||||
console.error("failed to upload image", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@ -14,8 +18,11 @@ export const uploadImageAction = handleImageUpload({
|
||||
if (!file.type.includes("image/")) {
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 20) {
|
||||
//error("File size too big (max 20MB).");
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@ -12,9 +12,11 @@ import {
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconMovie,
|
||||
IconPaperclip,
|
||||
IconPhoto,
|
||||
IconTable,
|
||||
IconTypography,
|
||||
IconMenu4
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@ -22,6 +24,10 @@ import {
|
||||
} 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";
|
||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@ -118,15 +124,23 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
description: "Insert code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: IconCode,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Insert horizontal rule divider",
|
||||
searchTerms: ["horizontal rule", "hr"],
|
||||
icon: IconMenu4,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
description: "Upload any image from your device.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: IconPhoto,
|
||||
command: ({ editor, range }) => {
|
||||
@ -139,11 +153,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@ -151,7 +167,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
description: "Upload an video from your computer.",
|
||||
description: "Upload any video from your device.",
|
||||
searchTerms: ["video", "mp4", "media"],
|
||||
icon: IconMovie,
|
||||
command: ({ editor, range }) => {
|
||||
@ -174,6 +190,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "File attachment",
|
||||
description: "Upload any file from your device.",
|
||||
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
||||
icon: IconPaperclip,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
// upload file
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
if (file.type.includes("image/*")) {
|
||||
uploadImageAction(file, editor.view, pos, pageId);
|
||||
} else if (file.type.includes("video/*")) {
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
} else {
|
||||
uploadAttachmentAction(file, editor.view, pos, pageId);
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Insert a table.",
|
||||
@ -253,6 +300,36 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setMathBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Mermaid diagram",
|
||||
description: "Insert mermaid diagram",
|
||||
searchTerms: ["mermaid", "diagrams", "chart", "uml"],
|
||||
icon: IconMermaid,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setCodeBlock({ language: "mermaid" })
|
||||
.insertContent("flowchart LR\n" + " A --> B")
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Draw.io (diagrams.net) ",
|
||||
description: "Insert and design Drawio diagrams",
|
||||
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
|
||||
icon: IconDrawio,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDrawio().run(),
|
||||
},
|
||||
{
|
||||
title: "Excalidraw diagram",
|
||||
description: "Draw and sketch excalidraw diagrams",
|
||||
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
|
||||
icon: IconExcalidraw,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setExcalidraw().run(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { handleVideoUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
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);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: err?.response.data.message,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@ -15,7 +19,11 @@ export const uploadVideoAction = handleVideoUpload({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size / 1024 / 1024 > 20) {
|
||||
if (file.size / 1024 / 1024 > 50) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `File exceeds the 50 MB attachment limit`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@ -12,7 +12,6 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Table from "@tiptap/extension-table";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
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";
|
||||
@ -32,6 +31,10 @@ import {
|
||||
TiptapVideo,
|
||||
LinkExtension,
|
||||
Selection,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@ -46,7 +49,14 @@ 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";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
@ -56,6 +66,11 @@ export const mainExtensions = [
|
||||
color: "#70CFF8",
|
||||
},
|
||||
codeBlock: false,
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
@ -70,6 +85,7 @@ export const mainExtensions = [
|
||||
}
|
||||
},
|
||||
includeChildren: true,
|
||||
showOnlyWhenEditable: true,
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
@ -129,10 +145,23 @@ export const mainExtensions = [
|
||||
Callout.configure({
|
||||
view: CalloutView,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
CustomCodeBlock.configure({
|
||||
view: CodeBlockView,
|
||||
lowlight,
|
||||
HTMLAttributes: {
|
||||
spellcheck: false,
|
||||
},
|
||||
}),
|
||||
Selection,
|
||||
Attachment.configure({
|
||||
view: AttachmentView,
|
||||
}),
|
||||
Drawio.configure({
|
||||
view: DrawioView,
|
||||
}),
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@ -31,14 +31,14 @@ import TableCellMenu from "@/features/editor/components/table/table-cell-menu.ts
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
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";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handleFilePaste,
|
||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -173,6 +173,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
.ProseMirror {
|
||||
.codeBlock {
|
||||
padding: 4px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
margin: 4px;
|
||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||
border-radius: var(--mantine-radius-default);
|
||||
|
||||
@ -86,4 +93,22 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
|
||||
line-height: var(--mantine-line-height);
|
||||
padding: 2px calc(var(--mantine-spacing-xs) / 2);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
margin: 0;
|
||||
|
||||
@mixin where-light {
|
||||
background-color: var(--code-bg, var(--mantine-color-gray-1));
|
||||
color: var(--mantine-color-black);
|
||||
}
|
||||
|
||||
@mixin where-dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
color: var(--mantine-color-gray-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,11 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0.65em;
|
||||
margin-bottom: 0.65em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
@ -27,6 +32,12 @@
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
ul p,
|
||||
ol p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@ -80,15 +91,9 @@
|
||||
outline: 2px solid #70cff8;
|
||||
}
|
||||
|
||||
.node-mathInline {
|
||||
.katex-display {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > .react-renderer {
|
||||
margin-top: var(--mantine-spacing-xl);
|
||||
margin-bottom: var(--mantine-spacing-xl);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
|
||||
@ -50,8 +50,7 @@
|
||||
|
||||
[data-type="detailsContainer"] {
|
||||
flex: 1;
|
||||
margin-left: 0.2em;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
|
||||
@ -9,6 +9,3 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
|
||||
|
||||
|
||||
|
||||
@ -4,10 +4,34 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.node-image, .node-video {
|
||||
.node-image, .node-video, .node-excalidraw, .node-drawio {
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-radius: var(--mantine-radius-default);
|
||||
cursor: pointer;
|
||||
padding: 15px;
|
||||
height: 25px;
|
||||
|
||||
@mixin light {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border: 1px solid var(--mantine-color-dark-4);
|
||||
}
|
||||
}
|
||||
|
||||
.uploading-text {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
.ProseMirror table .is-editor-empty:first-child::before,
|
||||
.ProseMirror table .is-empty::before {
|
||||
content: '';
|
||||
display: none;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@ -81,10 +81,14 @@ export async function importPage(file: File, spaceId: string) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, pageId: string) {
|
||||
export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise<IAttachment> {
|
||||
const formData = new FormData();
|
||||
if(attachmentId){
|
||||
formData.append("attachmentId", attachmentId);
|
||||
}
|
||||
formData.append("pageId", pageId);
|
||||
formData.append("file", file);
|
||||
|
||||
|
||||
const req = await api.post<IAttachment>("/files/upload", formData, {
|
||||
headers: {
|
||||
@ -92,5 +96,5 @@ export async function uploadFile(file: File, pageId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return req;
|
||||
return req as unknown as IAttachment;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export const SOCKET_URL = import.meta.env.DEV
|
||||
? "http://localhost:3000"
|
||||
? process.env.APP_URL
|
||||
: undefined;
|
||||
|
||||
@ -5,24 +5,29 @@ declare global {
|
||||
}
|
||||
|
||||
export function getAppUrl(): string {
|
||||
let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
||||
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
return appUrl || "http://localhost:3000";
|
||||
}
|
||||
// if (import.meta.env.DEV) {
|
||||
// return appUrl || "http://localhost:3000";
|
||||
//}
|
||||
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
}
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
return getAppUrl() + "/api";
|
||||
return getAppUrl() + '/api';
|
||||
}
|
||||
|
||||
export function getCollaborationUrl(): string {
|
||||
const COLLAB_PATH = "/collab";
|
||||
const COLLAB_PATH = '/collab';
|
||||
|
||||
const wsProtocol = getAppUrl().startsWith("https") ? "wss" : "ws";
|
||||
return `${wsProtocol}://${getAppUrl().split("://")[1]}${COLLAB_PATH}`;
|
||||
let url = getAppUrl();
|
||||
if (import.meta.env.DEV) {
|
||||
url = process.env.APP_URL;
|
||||
}
|
||||
|
||||
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
|
||||
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(avatarUrl: string) {
|
||||
@ -30,17 +35,17 @@ export function getAvatarUrl(avatarUrl: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (avatarUrl.startsWith("http")) {
|
||||
if (avatarUrl?.startsWith('http')) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
|
||||
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
|
||||
}
|
||||
|
||||
export function getSpaceUrl(spaceSlug: string) {
|
||||
return "/s/" + spaceSlug;
|
||||
return '/s/' + spaceSlug;
|
||||
}
|
||||
|
||||
export function getFileUrl(src: string) {
|
||||
return src.startsWith("/files/") ? getBackendUrl() + src : src;
|
||||
return src?.startsWith('/files/') ? getBackendUrl() + src : src;
|
||||
}
|
||||
|
||||
@ -25,3 +25,39 @@ export const computeSpaceSlug = (name: string) => {
|
||||
return alphanumericName.toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
export const formatBytes = (
|
||||
bytes: number,
|
||||
decimalPlaces: number = 2,
|
||||
): string => {
|
||||
if (bytes === 0) return "0.0 KB";
|
||||
|
||||
const unitSize = 1024;
|
||||
const precision = decimalPlaces < 0 ? 0 : decimalPlaces;
|
||||
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const kilobytes = bytes / unitSize;
|
||||
|
||||
const unitIndex = Math.floor(Math.log(kilobytes) / Math.log(unitSize));
|
||||
const adjustedUnitIndex = Math.max(unitIndex, 0);
|
||||
const adjustedSize = kilobytes / Math.pow(unitSize, adjustedUnitIndex);
|
||||
|
||||
return `${adjustedSize.toFixed(precision)} ${units[adjustedUnitIndex]}`;
|
||||
};
|
||||
|
||||
export async function svgStringToFile(
|
||||
svgString: string,
|
||||
fileName: string,
|
||||
): Promise<File> {
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml" });
|
||||
return new File([blob], fileName, { type: "image/svg+xml" });
|
||||
}
|
||||
|
||||
export function decodeBase64ToSvgString(base64Data: string): string {
|
||||
const base64Prefix = 'data:image/svg+xml;base64,';
|
||||
if (base64Data.startsWith(base64Prefix)) {
|
||||
base64Data = base64Data.replace(base64Prefix, '');
|
||||
}
|
||||
|
||||
return atob(base64Data);
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
|
||||
1
apps/client/src/vite-env.d.ts
vendored
1
apps/client/src/vite-env.d.ts
vendored
@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const APP_VERSION: string
|
||||
|
||||
@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => {
|
||||
"process.env": {
|
||||
APP_URL,
|
||||
},
|
||||
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.2.8",
|
||||
"version": "0.3.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@ -28,40 +28,38 @@
|
||||
"test:e2e": "jest --config test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.600.0",
|
||||
"@aws-sdk/client-s3": "^3.637.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.637.0",
|
||||
"@casl/ability": "^6.7.1",
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@nestjs/bullmq": "^10.1.1",
|
||||
"@nestjs/common": "^10.3.9",
|
||||
"@nestjs/config": "^3.2.2",
|
||||
"@nestjs/core": "^10.3.9",
|
||||
"@nestjs/bullmq": "^10.2.1",
|
||||
"@nestjs/common": "^10.4.1",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.4.1",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.5",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-fastify": "^10.3.9",
|
||||
"@nestjs/platform-socket.io": "^10.3.9",
|
||||
"@nestjs/platform-fastify": "^10.4.1",
|
||||
"@nestjs/platform-socket.io": "^10.4.1",
|
||||
"@nestjs/terminus": "^10.2.3",
|
||||
"@nestjs/websockets": "^10.3.9",
|
||||
"@react-email/components": "0.0.19",
|
||||
"@react-email/render": "^0.0.15",
|
||||
"@nestjs/websockets": "^10.4.1",
|
||||
"@react-email/components": "0.0.24",
|
||||
"@react-email/render": "^1.0.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^5.8.2",
|
||||
"bullmq": "^5.12.12",
|
||||
"bytes": "^3.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"fastify": "^4.28.0",
|
||||
"fix-esm": "^1.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"happy-dom": "^14.12.3",
|
||||
"kysely": "^0.27.3",
|
||||
"happy-dom": "^15.7.3",
|
||||
"kysely": "^0.27.4",
|
||||
"kysely-migration-cli": "^0.4.2",
|
||||
"marked": "^13.0.2",
|
||||
"marked": "^13.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-kysely": "^1.0.0",
|
||||
@ -69,46 +67,47 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"postmark": "^4.0.4",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"redis": "^4.6.14",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename-ts": "^1.0.2",
|
||||
"socket.io": "^4.7.5",
|
||||
"ws": "^8.17.1"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@nestjs/testing": "^10.3.9",
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bytes": "^3.1.4",
|
||||
"@types/debounce": "^1.2.4",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/node": "^22.5.2",
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.11.8",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.14.1",
|
||||
"eslint": "^9.5.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.15.0",
|
||||
"prettier": "^3.3.2",
|
||||
"react-email": "^2.1.4",
|
||||
"kysely-codegen": "^0.16.3",
|
||||
"prettier": "^3.3.3",
|
||||
"react-email": "^3.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.1.5",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.2"
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@ -15,6 +15,7 @@ import TableHeader from '@tiptap/extension-table-header';
|
||||
import {
|
||||
Callout,
|
||||
Comment,
|
||||
CustomCodeBlock,
|
||||
Details,
|
||||
DetailsContent,
|
||||
DetailsSummary,
|
||||
@ -26,6 +27,9 @@ import {
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TrailingNode,
|
||||
Attachment,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
@ -35,7 +39,9 @@ import { generateHTML } from '../common/helpers/prosemirror/html';
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
}),
|
||||
Comment,
|
||||
TextAlign,
|
||||
TaskList,
|
||||
@ -62,6 +68,10 @@ export const tiptapExtensions = [
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
Callout,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@ -10,10 +10,7 @@ export function generateJSON(
|
||||
const schema = getSchema(extensions);
|
||||
|
||||
const window = new Window();
|
||||
const dom = new HappyDomParser(window).parseFromString(
|
||||
html,
|
||||
'text/html',
|
||||
).body;
|
||||
const dom = new HappyDomParser().parseFromString(html, 'text/html').body;
|
||||
|
||||
// @ts-ignore
|
||||
return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
|
||||
|
||||
@ -5,10 +5,10 @@ export enum AttachmentType {
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg', 'gif'];
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
export const MAX_AVATAR_SIZE = '5MB';
|
||||
|
||||
export const InlineFileExtensions = [
|
||||
export const inlineFileExtensions = [
|
||||
'.jpg',
|
||||
'.png',
|
||||
'.jpeg',
|
||||
@ -16,4 +16,4 @@ export const InlineFileExtensions = [
|
||||
'.mp4',
|
||||
'.mov',
|
||||
];
|
||||
export const MAX_FILE_SIZE = '20MB';
|
||||
export const MAX_FILE_SIZE = '50MB';
|
||||
|
||||
@ -30,6 +30,7 @@ import {
|
||||
import { getMimeType } from '../../common/helpers';
|
||||
import {
|
||||
AttachmentType,
|
||||
inlineFileExtensions,
|
||||
MAX_AVATAR_SIZE,
|
||||
MAX_FILE_SIZE,
|
||||
} from './attachment.constants';
|
||||
@ -75,7 +76,7 @@ export class AttachmentController {
|
||||
let file = null;
|
||||
try {
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 2, files: 1 },
|
||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.error(err.message);
|
||||
@ -112,6 +113,11 @@ export class AttachmentController {
|
||||
|
||||
const spaceId = page.spaceId;
|
||||
|
||||
const attachmentId = file.fields?.attachmentId?.value;
|
||||
if (attachmentId && !isValidUUID(attachmentId)) {
|
||||
throw new BadRequestException('Invalid attachment id');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileResponse = await this.attachmentService.uploadFile({
|
||||
filePromise: file,
|
||||
@ -119,10 +125,16 @@ export class AttachmentController {
|
||||
spaceId: spaceId,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
attachmentId: attachmentId,
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
const errMessage = `File too large. Exceeds the ${MAX_FILE_SIZE} limit`;
|
||||
this.logger.error(errMessage);
|
||||
throw new BadRequestException(errMessage);
|
||||
}
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
@ -163,9 +175,17 @@ export class AttachmentController {
|
||||
try {
|
||||
const fileStream = await this.storageService.read(attachment.filePath);
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(attachment.filePath),
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${attachment.fileName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
|
||||
@ -38,7 +38,6 @@ export async function prepareFile(
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in file preparation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
@ -36,27 +41,64 @@ export class AttachmentService {
|
||||
userId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
attachmentId?: string;
|
||||
}) {
|
||||
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
|
||||
const attachmentId = uuid7();
|
||||
let isUpdate = false;
|
||||
let attachmentId = null;
|
||||
|
||||
// passing attachmentId to allow for updating diagrams
|
||||
// instead of creating new files for each save
|
||||
if (opts?.attachmentId) {
|
||||
let existingAttachment = await this.attachmentRepo.findById(
|
||||
opts.attachmentId,
|
||||
);
|
||||
if (!existingAttachment) {
|
||||
throw new NotFoundException(
|
||||
'Existing attachment to overwrite not found',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existingAttachment.pageId !== pageId &&
|
||||
existingAttachment.fileExt !== preparedFile.fileExtension &&
|
||||
existingAttachment.workspaceId !== workspaceId
|
||||
) {
|
||||
throw new BadRequestException('File attachment does not match');
|
||||
}
|
||||
attachmentId = opts.attachmentId;
|
||||
isUpdate = true;
|
||||
} else {
|
||||
attachmentId = uuid7();
|
||||
}
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||
|
||||
let attachment: Attachment = null;
|
||||
try {
|
||||
attachment = await this.saveAttachment({
|
||||
attachmentId,
|
||||
preparedFile,
|
||||
filePath,
|
||||
type: AttachmentType.File,
|
||||
userId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
});
|
||||
if (isUpdate) {
|
||||
attachment = await this.attachmentRepo.updateAttachment(
|
||||
{
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
attachmentId,
|
||||
);
|
||||
} else {
|
||||
attachment = await this.saveAttachment({
|
||||
attachmentId,
|
||||
preparedFile,
|
||||
filePath,
|
||||
type: AttachmentType.File,
|
||||
userId,
|
||||
spaceId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// delete uploaded file on error
|
||||
this.logger.error(err);
|
||||
|
||||
@ -43,8 +43,8 @@ export class AttachmentRepo {
|
||||
async updateAttachment(
|
||||
updatableAttachment: UpdatableAttachment,
|
||||
attachmentId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
): Promise<Attachment> {
|
||||
return await this.db
|
||||
.updateTable('attachments')
|
||||
.set(updatableAttachment)
|
||||
.where('id', '=', attachmentId)
|
||||
|
||||
@ -62,6 +62,10 @@ export class EnvironmentService {
|
||||
getAwsS3Endpoint(): string {
|
||||
return this.configService.get<string>('AWS_S3_ENDPOINT');
|
||||
}
|
||||
|
||||
getAwsS3ForcePathStyle(): boolean {
|
||||
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE')
|
||||
}
|
||||
|
||||
getAwsS3Url(): string {
|
||||
return this.configService.get<string>('AWS_S3_URL');
|
||||
|
||||
@ -34,19 +34,15 @@ export class ImportService {
|
||||
const fileBuffer = await file.toBuffer();
|
||||
const fileName = sanitize(file.filename).slice(0, 255).split('.')[0];
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
const fileMimeType = file.mimetype;
|
||||
const fileContent = fileBuffer.toString();
|
||||
|
||||
let prosemirrorState = null;
|
||||
let createdPage = null;
|
||||
|
||||
try {
|
||||
if (fileExtension.endsWith('.md') && fileMimeType === 'text/markdown') {
|
||||
if (fileExtension.endsWith('.md')) {
|
||||
prosemirrorState = await this.processMarkdown(fileContent);
|
||||
} else if (
|
||||
fileExtension.endsWith('.html') &&
|
||||
fileMimeType === 'text/html'
|
||||
) {
|
||||
} else if (fileExtension.endsWith('.html')) {
|
||||
prosemirrorState = await this.processHTML(fileContent);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -19,8 +19,8 @@ export class MailService {
|
||||
async sendEmail(message: MailMessage): Promise<void> {
|
||||
if (message.template) {
|
||||
// in case this method is used directly. we do not send the tsx template from queue
|
||||
message.html = render(message.template, { pretty: true });
|
||||
message.text = render(message.template, { plainText: true });
|
||||
message.html = await render(message.template, { pretty: true });
|
||||
message.text = await render(message.template, { plainText: true });
|
||||
}
|
||||
|
||||
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
|
||||
@ -30,8 +30,8 @@ export class MailService {
|
||||
async sendToQueue(message: MailMessage): Promise<void> {
|
||||
if (message.template) {
|
||||
// transform the React object because it gets lost when sent via the queue
|
||||
message.html = render(message.template, { pretty: true });
|
||||
message.text = render(message.template, {
|
||||
message.html = await render(message.template, { pretty: true });
|
||||
message.text = await render(message.template, {
|
||||
plainText: true,
|
||||
});
|
||||
delete message.template;
|
||||
|
||||
@ -48,6 +48,7 @@ export const storageDriverConfigProvider = {
|
||||
endpoint: environmentService.getAwsS3Endpoint(),
|
||||
bucket: environmentService.getAwsS3Bucket(),
|
||||
baseUrl: environmentService.getAwsS3Url(),
|
||||
forcePathStyle: environmentService.getAwsS3ForcePathStyle(),
|
||||
credentials: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
77
package.json
77
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "docmost",
|
||||
"homepage": "https://docmost.com",
|
||||
"version": "0.2.8",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nx run-many -t build",
|
||||
@ -24,40 +24,41 @@
|
||||
"@joplin/turndown": "^4.0.74",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.56",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tiptap/core": "^2.5.4",
|
||||
"@tiptap/extension-code-block": "^2.5.4",
|
||||
"@tiptap/extension-collaboration": "^2.5.4",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.5.4",
|
||||
"@tiptap/extension-color": "^2.5.4",
|
||||
"@tiptap/extension-document": "^2.5.4",
|
||||
"@tiptap/extension-heading": "^2.5.4",
|
||||
"@tiptap/extension-highlight": "^2.5.4",
|
||||
"@tiptap/extension-history": "^2.5.4",
|
||||
"@tiptap/extension-image": "^2.5.4",
|
||||
"@tiptap/extension-link": "^2.5.4",
|
||||
"@tiptap/extension-list-item": "^2.5.4",
|
||||
"@tiptap/extension-list-keymap": "^2.5.4",
|
||||
"@tiptap/extension-mention": "^2.5.4",
|
||||
"@tiptap/extension-placeholder": "^2.5.4",
|
||||
"@tiptap/extension-subscript": "^2.5.4",
|
||||
"@tiptap/extension-superscript": "^2.5.4",
|
||||
"@tiptap/extension-table": "^2.5.4",
|
||||
"@tiptap/extension-table-cell": "^2.5.4",
|
||||
"@tiptap/extension-table-header": "^2.5.4",
|
||||
"@tiptap/extension-table-row": "^2.5.4",
|
||||
"@tiptap/extension-task-item": "^2.5.4",
|
||||
"@tiptap/extension-task-list": "^2.5.4",
|
||||
"@tiptap/extension-text": "^2.5.4",
|
||||
"@tiptap/extension-text-align": "^2.5.4",
|
||||
"@tiptap/extension-text-style": "^2.5.4",
|
||||
"@tiptap/extension-typography": "^2.5.4",
|
||||
"@tiptap/extension-underline": "^2.5.4",
|
||||
"@tiptap/extension-youtube": "^2.5.4",
|
||||
"@tiptap/html": "^2.5.4",
|
||||
"@tiptap/pm": "^2.5.4",
|
||||
"@tiptap/react": "^2.5.4",
|
||||
"@tiptap/starter-kit": "^2.5.4",
|
||||
"@tiptap/suggestion": "^2.5.4",
|
||||
"@tiptap/core": "^2.6.6",
|
||||
"@tiptap/extension-code-block": "^2.6.6",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.6.6",
|
||||
"@tiptap/extension-collaboration": "^2.6.6",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.6.6",
|
||||
"@tiptap/extension-color": "^2.6.6",
|
||||
"@tiptap/extension-document": "^2.6.6",
|
||||
"@tiptap/extension-heading": "^2.6.6",
|
||||
"@tiptap/extension-highlight": "^2.6.6",
|
||||
"@tiptap/extension-history": "^2.6.6",
|
||||
"@tiptap/extension-image": "^2.6.6",
|
||||
"@tiptap/extension-link": "^2.6.6",
|
||||
"@tiptap/extension-list-item": "^2.6.6",
|
||||
"@tiptap/extension-list-keymap": "^2.6.6",
|
||||
"@tiptap/extension-mention": "^2.6.6",
|
||||
"@tiptap/extension-placeholder": "^2.6.6",
|
||||
"@tiptap/extension-subscript": "^2.6.6",
|
||||
"@tiptap/extension-superscript": "^2.6.6",
|
||||
"@tiptap/extension-table": "^2.6.6",
|
||||
"@tiptap/extension-table-cell": "^2.6.6",
|
||||
"@tiptap/extension-table-header": "^2.6.6",
|
||||
"@tiptap/extension-table-row": "^2.6.6",
|
||||
"@tiptap/extension-task-item": "^2.6.6",
|
||||
"@tiptap/extension-task-list": "^2.6.6",
|
||||
"@tiptap/extension-text": "^2.6.6",
|
||||
"@tiptap/extension-text-align": "^2.6.6",
|
||||
"@tiptap/extension-text-style": "^2.6.6",
|
||||
"@tiptap/extension-typography": "^2.6.6",
|
||||
"@tiptap/extension-underline": "^2.6.6",
|
||||
"@tiptap/extension-youtube": "^2.6.6",
|
||||
"@tiptap/html": "^2.6.6",
|
||||
"@tiptap/pm": "^2.6.6",
|
||||
"@tiptap/react": "^2.6.6",
|
||||
"@tiptap/starter-kit": "^2.6.6",
|
||||
"@tiptap/suggestion": "^2.6.6",
|
||||
"cross-env": "^7.0.3",
|
||||
"fractional-indexing-jittered": "^0.9.1",
|
||||
"ioredis": "^5.4.1",
|
||||
@ -66,11 +67,11 @@
|
||||
"yjs": "^13.6.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "19.3.2",
|
||||
"@nx/js": "19.6.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"nx": "19.3.2",
|
||||
"tsx": "^4.15.7"
|
||||
"nx": "19.6.3",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@ -8,6 +8,5 @@
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "./src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"dependencies": {}
|
||||
"types": "dist/index.d.ts"
|
||||
}
|
||||
|
||||
@ -10,3 +10,8 @@ export * from "./lib/callout";
|
||||
export * from "./lib/media-utils";
|
||||
export * from "./lib/link";
|
||||
export * from "./lib/selection";
|
||||
export * from "./lib/attachment";
|
||||
export * from "./lib/custom-code-block"
|
||||
export * from "./lib/drawio";
|
||||
export * from "./lib/excalidraw";
|
||||
|
||||
|
||||
119
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
119
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
|
||||
const uploadKey = new PluginKey("attachment-upload");
|
||||
|
||||
export const AttachmentUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, fileName } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", placeholderClass);
|
||||
|
||||
const uploadingText = document.createElement("span");
|
||||
uploadingText.setAttribute("class", "uploading-text");
|
||||
uploadingText.textContent = `Uploading ${fileName}`;
|
||||
|
||||
placeholder.appendChild(uploadingText);
|
||||
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleAttachmentUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
fileName: file.name,
|
||||
},
|
||||
});
|
||||
view.dispatch(tr);
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
if (pos == null) return;
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.attachment?.create({
|
||||
url: `/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
mime: attachment.mimeType,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
};
|
||||
123
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
123
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { AttachmentUploadPlugin } from "./attachment-upload";
|
||||
|
||||
export interface AttachmentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
export interface AttachmentAttributes {
|
||||
url?: string;
|
||||
name?: string;
|
||||
mime?: string; // mime type e.g. application/zip
|
||||
size?: number;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
attachment: {
|
||||
setAttachment: (attributes: AttachmentAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Attachment = Node.create<AttachmentOptions>({
|
||||
name: "attachment",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
url: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-url"),
|
||||
renderHTML: (attributes) => ({
|
||||
"data-attachment-url": attributes.url,
|
||||
}),
|
||||
},
|
||||
name: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-name"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-name": attributes.name,
|
||||
}),
|
||||
},
|
||||
mime: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-mime"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-mime": attributes.mime,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-size"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: AttachmentAttributes) => ({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setAttachment:
|
||||
(attrs: AttachmentAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "attachment",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
AttachmentUploadPlugin({
|
||||
placeholderClass: "attachment-placeholder",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Attachment } from "./attachment";
|
||||
export * from "./attachment-upload";
|
||||
25
packages/editor-ext/src/lib/custom-code-block.ts
Normal file
25
packages/editor-ext/src/lib/custom-code-block.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import CodeBlockLowlight, {
|
||||
CodeBlockLowlightOptions,
|
||||
} from "@tiptap/extension-code-block-lowlight";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
||||
view: any;
|
||||
}
|
||||
|
||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
{
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
},
|
||||
);
|
||||
124
packages/editor-ext/src/lib/drawio.ts
Normal file
124
packages/editor-ext/src/lib/drawio.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface DrawioOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
export interface DrawioAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
size?: number;
|
||||
width?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
drawio: {
|
||||
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Drawio = Node.create<DrawioOptions>({
|
||||
name: "drawio",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setDrawio:
|
||||
(attrs: DrawioAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "drawio",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
123
packages/editor-ext/src/lib/excalidraw.ts
Normal file
123
packages/editor-ext/src/lib/excalidraw.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
export interface ExcalidrawOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
export interface ExcalidrawAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
size?: number;
|
||||
width?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
excalidraw: {
|
||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
name: 'excalidraw',
|
||||
inline: false,
|
||||
group: 'block',
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setExcalidraw:
|
||||
(attrs: ExcalidrawAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: 'excalidraw',
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
6663
pnpm-lock.yaml
generated
6663
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user