Compare commits

...

41 Commits

Author SHA1 Message Date
dea9f4c063 remove unnecessary log 2024-09-13 22:37:38 +01:00
0b6730c06f fix page export failure when title contains non-ASCII characters (#309) 2024-09-13 17:40:24 +01:00
be0d97661a update README 2024-09-04 18:56:14 +01:00
4e2b23c97e v0.3.1 2024-09-03 10:49:38 +01:00
dc3ce27762 fix collaboration websocket 2024-09-03 10:48:47 +01:00
8af2d4e8cf file content-disposition 2024-09-02 16:39:07 +01:00
73ddec4ca7 v0.3.0 2024-09-02 15:56:24 +01:00
2b9765fb35 lazy load (#237) 2024-09-02 15:51:28 +01:00
7fdd355cc3 Reduce version text size 2024-09-02 13:08:01 +01:00
6c6b47599a update dependencies 2024-09-02 12:43:33 +01:00
7e6a71fa2d add HR divider to slash menu 2024-09-02 01:28:15 +01:00
1141796f24 Show version
* Add default mermaid content
2024-09-01 17:30:34 +01:00
11dbc079be Add home link to logo 2024-09-01 16:24:20 +01:00
87b99f8646 feat: draw.io (diagrams.net) integration (#215)
* draw.io init

* updates
2024-09-01 12:26:20 +01:00
38e9eef2dc feat: excalidraw integration (#214)
* update tiptap version

* excalidraw init

* cleanup

* better file handling and other fixes

* use different modal to fix excalidraw cursor position issue
* see https://github.com/excalidraw/excalidraw/issues/7312
* fix websocket in vite dev mode

* WIP

* add align attribute

* fix table

* menu icons

* Render image in excalidraw html
* add size to custom SVG components

* rewrite undefined font urls
2024-08-31 19:11:07 +01:00
77b541ec71 Fix mime attribute 2024-08-26 17:12:59 +01:00
7dc37b933f feat: editor file attachments (#194)
* fix current slider value

* WIP

* changes to extension attributes

* update command title
2024-08-26 12:38:47 +01:00
7e80797e3f feat: mermaid diagram integration (#202) 2024-08-24 18:30:07 +01:00
17475bf123 feat: code block language selection (#198)
* code block language selection

* cleanup

* Add copy button
2024-08-24 18:12:19 +01:00
4433d5174d Add Source Label to Dockerfile (#157) 2024-08-20 13:09:36 +01:00
c810d0b314 fix: added env variable for support for forcepathstyle on s3 (#181) 2024-08-20 13:05:59 +01:00
463480ae67 v0.2.10 (fix) 2024-08-07 23:03:00 +02:00
2449d69fab v2.9.10 2024-08-07 22:22:49 +02:00
e0d74fcb0e Fix: editor formatting (#137)
* reduce space between list items

* reduce spacing

* Make inline code readable in dark mode
* Disable spellcheck in code

* fix numbered list in toggle block
2024-08-04 10:06:22 +02:00
4967849e3a add SMTP_SECURE 2024-08-02 11:19:12 +02:00
0a447e91bb fix markdown import 2024-07-22 18:39:44 +01:00
48e76aa9f4 v0.2.8 2024-07-22 16:36:06 +01:00
2bd6422a35 Show new workspace role on change 2024-07-22 16:35:00 +01:00
407a1aff3b only owner can assign owner role (#108)
* backend fix: b4bc184cb3
2024-07-22 16:18:09 +01:00
b4bc184cb3 prevent admin role from managing owner role (backend) 2024-07-22 16:16:33 +01:00
109dbdbe02 cleanup log 2024-07-22 15:59:43 +01:00
2df7de5828 fix table commands type error 2024-07-22 15:43:43 +01:00
373fc86e47 preserve details tag in markdown export 2024-07-22 14:09:52 +01:00
5052a9ea40 Support math export in Markdown 2024-07-22 13:20:00 +01:00
cd47c79d86 Make math node handling better 2024-07-22 13:05:07 +01:00
78746938b7 fix export format state 2024-07-22 13:02:13 +01:00
4d2936627c fix: generate ydoc state during page import to prevent duplicate nodes on the editor 2024-07-22 11:02:43 +01:00
d2ecd28047 fix: localize attachment type
* fixes #86
2024-07-22 10:58:32 +01:00
bb92ca75e9 use logger 2024-07-21 21:57:31 +01:00
8f3e2ff663 fix editor placeholder bug 2024-07-21 20:50:08 +01:00
89f6311e46 * Make page import handling better 2024-07-21 20:48:33 +01:00
82 changed files with 5298 additions and 3888 deletions

View File

@ -19,6 +19,7 @@ AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_REGION= AWS_S3_REGION=
AWS_S3_BUCKET= AWS_S3_BUCKET=
AWS_S3_ENDPOINT= AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE=
# options: smtp | postmark # options: smtp | postmark
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
@ -30,6 +31,7 @@ SMTP_HOST=127.0.0.1
SMTP_PORT=587 SMTP_PORT=587
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_SECURE=false
# Postmark driver config # Postmark driver config
POSTMARK_TOKEN= POSTMARK_TOKEN=

View File

@ -1,4 +1,5 @@
FROM node:21-alpine AS base FROM node:21-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder FROM base AS builder

View File

@ -17,6 +17,7 @@ To get started with Docmost, please refer to our [documentation](https://docmost
## Features ## Features
- Real-time collaboration - Real-time collaboration
- Diagrams (Draw.io, Excalidraw and Mermaid)
- Spaces - Spaces
- Permissions management - Permissions management
- Groups - Groups
@ -32,4 +33,4 @@ To get started with Docmost, please refer to our [documentation](https://docmost
</p> </p>
### Contributing ### Contributing
See the [development doc](https://docmost.com/docs/self-hosting/development) See the [development documentation](https://docmost.com/docs/self-hosting/development)

View File

@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.2.7", "version": "0.3.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@ -13,58 +13,61 @@
"@casl/react": "^4.0.0", "@casl/react": "^4.0.0",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@mantine/core": "^7.11.0", "@excalidraw/excalidraw": "^0.17.6",
"@mantine/form": "^7.11.0", "@mantine/core": "^7.12.2",
"@mantine/hooks": "^7.11.0", "@mantine/form": "^7.12.2",
"@mantine/modals": "^7.11.0", "@mantine/hooks": "^7.12.2",
"@mantine/notifications": "^7.11.0", "@mantine/modals": "^7.12.2",
"@mantine/spotlight": "^7.11.0", "@mantine/notifications": "^7.12.2",
"@tabler/icons-react": "^3.7.0", "@mantine/spotlight": "^7.12.2",
"@tanstack/react-query": "^5.48.0", "@tabler/icons-react": "^3.14.0",
"@tiptap/extension-code-block-lowlight": "^2.4.0", "@tanstack/react-query": "^5.53.2",
"axios": "^1.7.2", "axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jotai": "^2.8.3", "jotai": "^2.9.3",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "^0.16.10", "katex": "^0.16.11",
"lowlight": "^3.1.0", "lowlight": "^3.1.0",
"mermaid": "^11.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-clear-modal": "^2.0.9",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0", "react-moveable": "^0.56.0",
"react-router-dom": "^6.24.0", "react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7", "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" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/eslint-plugin-query": "^5.47.0", "@tanstack/eslint-plugin-query": "^5.53.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "20.14.9", "@types/node": "22.5.2",
"@types/react": "^18.3.3", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^7.14.1", "@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.5.0", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^4.6.2", "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", "optics-ts": "^2.4.1",
"postcss": "^8.4.38", "postcss": "^8.4.43",
"postcss-preset-mantine": "^1.15.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"typescript": "^5.5.2", "typescript": "^5.5.4",
"vite": "^5.3.1" "vite": "^5.4.2"
} }
} }

View 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;

View 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;

View 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;

View File

@ -57,6 +57,8 @@ export function AppHeader() {
size="lg" size="lg"
fw={600} fw={600}
style={{ cursor: "pointer", userSelect: "none" }} style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
> >
Docmost Docmost
</Text> </Text>

View File

@ -93,6 +93,18 @@ export default function SettingsSidebar() {
</Group> </Group>
<ScrollArea w="100%">{menuItems}</ScrollArea> <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> </div>
); );
} }

View File

@ -57,3 +57,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
.version {
padding-left: var(--mantine-spacing-xs) ;
padding-top: 10px;
}

View File

@ -1,13 +1,14 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from 'react';
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { import {
ActionIcon, ActionIcon,
Popover, Popover,
Button, Button,
useMantineColorScheme, useMantineColorScheme,
} from "@mantine/core"; } from '@mantine/core';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from '@mantine/hooks';
import { Suspense } from 'react';
const Picker = React.lazy(() => import('@emoji-mart/react'));
export interface EmojiPickerInterface { export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void; onEmojiSelect: (emoji: any) => void;
@ -48,23 +49,25 @@ function EmojiPicker({
{icon} {icon}
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>
<Popover.Dropdown bg="000" style={{ border: "none" }}> <Popover.Dropdown bg="000" style={{ border: 'none' }}>
<Suspense fallback={null}>
<Picker <Picker
data={data} data={async () => (await import('@emoji-mart/data')).default}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
perLine={8} perLine={8}
skinTonePosition="search" skinTonePosition="search"
theme={colorScheme} theme={colorScheme}
/> />
</Suspense>
<Button <Button
variant="default" variant="default"
c="gray" c="gray"
size="xs" size="xs"
style={{ style={{
position: "absolute", position: 'absolute',
zIndex: 2, zIndex: 2,
bottom: "1rem", bottom: '1rem',
right: "1rem", right: '1rem',
}} }}
onClick={handleRemoveEmoji} onClick={handleRemoveEmoji}
> >

View File

@ -23,7 +23,7 @@ const RoleButton = forwardRef<HTMLButtonElement, RoleButtonProps>(
), ),
); );
interface SpaceRoleMenuProps { interface RoleMenuProps {
roles: IRoleData[]; roles: IRoleData[];
roleName: string; roleName: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@ -35,7 +35,7 @@ export default function RoleSelectMenu({
roleName, roleName,
onChange, onChange,
disabled, disabled,
}: SpaceRoleMenuProps) { }: RoleMenuProps) {
return ( return (
<Menu withArrow> <Menu withArrow>
<Menu.Target> <Menu.Target>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import type { EditorView } from "@tiptap/pm/view"; import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
export const handleFilePaste = ( export const handleFilePaste = (
view: EditorView, view: EditorView,
@ -15,6 +16,7 @@ export const handleFilePaste = (
if (file) { if (file) {
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
} }
return true; return true;
} }
@ -38,6 +40,7 @@ export const handleFileDrop = (
if (file) { if (file) {
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(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; return true;
} }

View File

@ -1,28 +1,34 @@
import React, { memo, useCallback, useState } from "react"; import { memo, useCallback, useEffect, useState } from 'react';
import { Slider } from "@mantine/core"; import { Slider } from '@mantine/core';
export type ImageWidthProps = { export type ImageWidthProps = {
onChange: (value: number) => void; onChange: (value: number) => void;
value: number; value: number;
width?: string;
}; };
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => { export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
const [currentValue, setCurrentValue] = useState(value); const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = useCallback( const handleChange = useCallback(
(newValue: number) => { (newValue: number) => {
onChange(newValue); onChange(newValue);
}, },
[onChange], [onChange]
); );
return ( return (
<Slider <Slider
p={"sm"} p={'sm'}
min={10} min={10}
value={currentValue} value={currentValue}
onChange={setCurrentValue} onChange={setCurrentValue}
onChangeEnd={handleChange} onChangeEnd={handleChange}
w={width || 100}
label={(value) => `${value}%`} label={(value) => `${value}%`}
/> />
); );

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -1,12 +1,16 @@
import { handleImageUpload } from "@docmost/editor-ext"; import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
export const uploadImageAction = handleImageUpload({ export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
try { try {
return await uploadFile(file, pageId); return await uploadFile(file, pageId);
} catch (err) { } catch (err) {
console.error("failed to upload image", err); notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err; throw err;
} }
}, },
@ -14,8 +18,11 @@ export const uploadImageAction = handleImageUpload({
if (!file.type.includes("image/")) { if (!file.type.includes("image/")) {
return false; return false;
} }
if (file.size / 1024 / 1024 > 20) { if (file.size / 1024 / 1024 > 50) {
//error("File size too big (max 20MB)."); notifications.show({
color: "red",
message: `File exceeds the 50 MB attachment limit`,
});
return false; return false;
} }
return true; return true;

View File

@ -12,9 +12,11 @@ import {
IconMath, IconMath,
IconMathFunction, IconMathFunction,
IconMovie, IconMovie,
IconPaperclip,
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@ -22,6 +24,10 @@ import {
} from "@/features/editor/components/slash-menu/types"; } from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-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 = { const CommandGroups: SlashMenuGroupedItemsType = {
basic: [ basic: [
@ -118,15 +124,23 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
{ {
title: "Code", title: "Code",
description: "Capture a code snippet.", description: "Insert code snippet.",
searchTerms: ["codeblock"], searchTerms: ["codeblock"],
icon: IconCode, icon: IconCode,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), 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", title: "Image",
description: "Upload an image from your computer.", description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"], searchTerms: ["photo", "picture", "media"],
icon: IconPhoto, icon: IconPhoto,
command: ({ editor, range }) => { command: ({ editor, range }) => {
@ -139,19 +153,21 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId); uploadImageAction(file, editor.view, pos, pageId);
} }
}
}; };
input.click(); input.click();
}, },
}, },
{ {
title: "Video", title: "Video",
description: "Upload an video from your computer.", description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"], searchTerms: ["video", "mp4", "media"],
icon: IconMovie, icon: IconMovie,
command: ({ editor, range }) => { command: ({ editor, range }) => {
@ -174,6 +190,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click(); 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", title: "Table",
description: "Insert a table.", description: "Insert a table.",
@ -253,6 +300,36 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setMathBlock().run(), 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(),
},
], ],
}; };

View File

@ -1,12 +1,16 @@
import { handleVideoUpload } from "@docmost/editor-ext"; import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
export const uploadVideoAction = handleVideoUpload({ export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
try { try {
return await uploadFile(file, pageId); return await uploadFile(file, pageId);
} catch (err) { } catch (err) {
console.error("failed to upload image", err); notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err; throw err;
} }
}, },
@ -15,7 +19,11 @@ export const uploadVideoAction = handleVideoUpload({
return false; 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 false;
} }
return true; return true;

View File

@ -4,14 +4,14 @@ import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList } from "@tiptap/extension-task-list"; import { TaskList } from "@tiptap/extension-task-list";
import { TaskItem } from "@tiptap/extension-task-item"; import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline"; import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Highlight } from "@tiptap/extension-highlight"; import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration"; import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -23,8 +23,6 @@ import {
DetailsSummary, DetailsSummary,
MathBlock, MathBlock,
MathInline, MathInline,
Table,
TableHeader,
TableCell, TableCell,
TableRow, TableRow,
TrailingNode, TrailingNode,
@ -33,6 +31,10 @@ import {
TiptapVideo, TiptapVideo,
LinkExtension, LinkExtension,
Selection, Selection,
Attachment,
CustomCodeBlock,
Drawio,
Excalidraw,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@ -47,7 +49,14 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight"; import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; 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); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
@ -57,6 +66,11 @@ export const mainExtensions = [
color: "#70CFF8", color: "#70CFF8",
}, },
codeBlock: false, codeBlock: false,
code: {
HTMLAttributes: {
spellcheck: false,
},
},
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
@ -66,9 +80,12 @@ export const mainExtensions = [
if (node.type.name === "detailsSummary") { if (node.type.name === "detailsSummary") {
return "Toggle title"; return "Toggle title";
} }
if (node.type.name === "paragraph") {
return 'Write anything. Enter "/" for commands'; return 'Write anything. Enter "/" for commands';
}
}, },
includeChildren: true, includeChildren: true,
showOnlyWhenEditable: true,
}), }),
TextAlign.configure({ types: ["heading", "paragraph"] }), TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList, TaskList,
@ -95,10 +112,16 @@ export const mainExtensions = [
class: "comment-mark", class: "comment-mark",
}, },
}), }),
Table,
Table.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
}),
TableRow, TableRow,
TableCell, TableCell,
TableHeader, TableHeader,
MathInline.configure({ MathInline.configure({
view: MathInlineView, view: MathInlineView,
}), }),
@ -122,10 +145,23 @@ export const mainExtensions = [
Callout.configure({ Callout.configure({
view: CalloutView, view: CalloutView,
}), }),
CodeBlockLowlight.configure({ CustomCodeBlock.configure({
view: CodeBlockView,
lowlight, lowlight,
HTMLAttributes: {
spellcheck: false,
},
}), }),
Selection, Selection,
Attachment.configure({
view: AttachmentView,
}),
Drawio.configure({
view: DrawioView,
}),
Excalidraw.configure({
view: ExcalidrawView,
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -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 TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-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 VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import { import {
handleFileDrop, handleFileDrop,
handleFilePaste, handleFilePaste,
} from "@/features/editor/components/common/file-upload-handler.tsx"; } from "@/features/editor/components/common/file-upload-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.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 { interface PageEditorProps {
pageId: string; pageId: string;
@ -173,6 +173,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
<ImageMenu editor={editor} /> <ImageMenu editor={editor} />
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} /> <LinkMenu editor={editor} appendTo={menuContainerRef} />
</div> </div>
)} )}

View File

@ -1,6 +1,13 @@
.ProseMirror { .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 { 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); font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
border-radius: var(--mantine-radius-default); border-radius: var(--mantine-radius-default);
@ -86,4 +93,22 @@
font-weight: 700; 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);
}
}
} }

View File

@ -20,6 +20,11 @@
outline: none; outline: none;
} }
p {
margin-top: 0.65em;
margin-bottom: 0.65em;
}
ul, ul,
ol { ol {
padding: 0 1rem; padding: 0 1rem;
@ -27,6 +32,12 @@
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
ul p,
ol p {
margin-top: 0;
margin-bottom: 0;
}
h1, h1,
h2, h2,
h3, h3,
@ -80,15 +91,9 @@
outline: 2px solid #70cff8; outline: 2px solid #70cff8;
} }
.node-mathInline {
.katex-display {
margin: 0;
}
}
& > .react-renderer { & > .react-renderer {
margin-top: var(--mantine-spacing-xl); margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-xl); margin-bottom: var(--mantine-spacing-sm);
&:first-child { &:first-child {
margin-top: 0; margin-top: 0;

View File

@ -50,8 +50,7 @@
[data-type="detailsContainer"] { [data-type="detailsContainer"] {
flex: 1; flex: 1;
margin-left: 0.2em; padding: 4px;
overflow-x: hidden;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;

View File

@ -9,6 +9,3 @@
@import "./media.css"; @import "./media.css";
@import "./code.css"; @import "./code.css";
@import "./print.css"; @import "./print.css";

View File

@ -4,10 +4,34 @@
height: auto; height: auto;
} }
.node-image, .node-video { .node-image, .node-video, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
outline: none; 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);
}
} }

View File

@ -24,7 +24,7 @@
.ProseMirror table .is-editor-empty:first-child::before, .ProseMirror table .is-editor-empty:first-child::before,
.ProseMirror table .is-empty::before { .ProseMirror table .is-empty::before {
content: ''; display: none;
@media print { @media print {
display: none; display: none;

View File

@ -56,7 +56,7 @@ export default function PageExportModal({
<div> <div>
<Text size="md">Format</Text> <Text size="md">Format</Text>
</div> </div>
<ExportFormatSelection onChange={handleChange} /> <ExportFormatSelection format={format} onChange={handleChange} />
</Group> </Group>
<Group justify="center" mt="md"> <Group justify="center" mt="md">
@ -72,16 +72,17 @@ export default function PageExportModal({
} }
interface ExportFormatSelection { interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
function ExportFormatSelection({ onChange }: ExportFormatSelection) { function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
return ( return (
<Select <Select
data={[ data={[
{ value: "markdown", label: "Markdown" }, { value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" }, { value: "html", label: "HTML" },
]} ]}
defaultValue={ExportFormat.Markdown} defaultValue={format}
onChange={onChange} onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }} styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }} comboboxProps={{ width: "120" }}

View File

@ -84,6 +84,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
} }
} }
if (pages?.length > 0 && pageCount > 0) {
const newTreeNodes = buildTree(pages); const newTreeNodes = buildTree(pages);
const fullTree = treeData.concat(newTreeNodes); const fullTree = treeData.concat(newTreeNodes);
@ -91,7 +92,6 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
setTreeData(fullTree); setTreeData(fullTree);
} }
if (pageCount > 0) {
const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`; const pageCountText = pageCount === 1 ? "1 page" : `${pageCount} pages`;
notifications.update({ notifications.update({

View File

@ -64,7 +64,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, fileName); saveAs(req.data, decodeURIComponent(fileName));
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@ -81,8 +81,15 @@ export async function importPage(file: File, spaceId: string) {
return req.data; 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(); const formData = new FormData();
if (attachmentId) {
formData.append("attachmentId", attachmentId);
}
formData.append("pageId", pageId); formData.append("pageId", pageId);
formData.append("file", file); formData.append("file", file);
@ -92,5 +99,5 @@ export async function uploadFile(file: File, pageId: string) {
}, },
}); });
return req; return req as unknown as IAttachment;
} }

View File

@ -1,3 +1,4 @@
export const SOCKET_URL = import.meta.env.DEV export const SOCKET_URL = import.meta.env.DEV
? "http://localhost:3000" ? process.env.APP_URL
: undefined; : undefined;

View File

@ -11,11 +11,14 @@ import {
userRoleData, userRoleData,
} from "@/features/workspace/types/user-role-data.ts"; } from "@/features/workspace/types/user-role-data.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 }); const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 });
const changeMemberRoleMutation = useChangeMemberRoleMutation(); const changeMemberRoleMutation = useChangeMemberRoleMutation();
const { isAdmin } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const assignableUserRoles = isOwner ? userRoleData : userRoleData.filter((role) => role.value !== UserRole.OWNER);
const handleRoleChange = async ( const handleRoleChange = async (
userId: string, userId: string,
@ -69,7 +72,7 @@ export default function WorkspaceMembersTable() {
<Table.Td> <Table.Td>
<RoleSelectMenu <RoleSelectMenu
roles={userRoleData} roles={assignableUserRoles}
roleName={getUserRoleLabel(user.role)} roleName={getUserRoleLabel(user.role)}
onChange={(newRole) => onChange={(newRole) =>
handleRoleChange(user.id, user.role, newRole) handleRoleChange(user.id, user.role, newRole)

View File

@ -53,9 +53,10 @@ export function useChangeMemberRoleMutation() {
return useMutation<any, Error, any>({ return useMutation<any, Error, any>({
mutationFn: (data) => changeMemberRole(data), mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
// TODO: change in cache instead
notifications.show({ message: "Member role updated successfully" }); notifications.show({ message: "Member role updated successfully" });
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ["workspaceMembers", variables.spaceId], queryKey: ["workspaceMembers"],
}); });
}, },
onError: (error) => { onError: (error) => {

View File

@ -5,24 +5,29 @@ declare global {
} }
export function getAppUrl(): string { 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) { // if (import.meta.env.DEV) {
return appUrl || "http://localhost:3000"; // return appUrl || "http://localhost:3000";
} //}
return `${window.location.protocol}//${window.location.host}`; return `${window.location.protocol}//${window.location.host}`;
} }
export function getBackendUrl(): string { export function getBackendUrl(): string {
return getAppUrl() + "/api"; return getAppUrl() + '/api';
} }
export function getCollaborationUrl(): string { export function getCollaborationUrl(): string {
const COLLAB_PATH = "/collab"; const COLLAB_PATH = '/collab';
const wsProtocol = getAppUrl().startsWith("https") ? "wss" : "ws"; let url = getAppUrl();
return `${wsProtocol}://${getAppUrl().split("://")[1]}${COLLAB_PATH}`; 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) { export function getAvatarUrl(avatarUrl: string) {
@ -30,17 +35,17 @@ export function getAvatarUrl(avatarUrl: string) {
return null; return null;
} }
if (avatarUrl.startsWith("http")) { if (avatarUrl?.startsWith('http')) {
return avatarUrl; return avatarUrl;
} }
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl; return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
} }
export function getSpaceUrl(spaceSlug: string) { export function getSpaceUrl(spaceSlug: string) {
return "/s/" + spaceSlug; return '/s/' + spaceSlug;
} }
export function getFileUrl(src: string) { export function getFileUrl(src: string) {
return src.startsWith("/files/") ? getBackendUrl() + src : src; return src?.startsWith('/files/') ? getBackendUrl() + src : src;
} }

View File

@ -25,3 +25,39 @@ export const computeSpaceSlug = (name: string) => {
return alphanumericName.toLowerCase(); 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);
}

View File

@ -22,6 +22,7 @@ export const queryClient = new QueryClient({
}, },
}); });
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement, document.getElementById("root") as HTMLElement,
); );

View File

@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const APP_VERSION: string

View File

@ -12,6 +12,7 @@ export default defineConfig(({ mode }) => {
"process.env": { "process.env": {
APP_URL, APP_URL,
}, },
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
}, },
plugins: [react()], plugins: [react()],
resolve: { resolve: {

View File

@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.2.7", "version": "0.3.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@ -28,40 +28,38 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/client-s3": "^3.637.0",
"@aws-sdk/s3-request-presigner": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.637.0",
"@casl/ability": "^6.7.1", "@casl/ability": "^6.7.1",
"@fastify/cookie": "^9.3.1", "@fastify/cookie": "^9.4.0",
"@fastify/multipart": "^8.3.0", "@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@nestjs/bullmq": "^10.1.1", "@nestjs/bullmq": "^10.2.1",
"@nestjs/common": "^10.3.9", "@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.2", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.9", "@nestjs/core": "^10.4.1",
"@nestjs/event-emitter": "^2.0.4", "@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.5", "@nestjs/mapped-types": "^2.0.5",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.9", "@nestjs/platform-fastify": "^10.4.1",
"@nestjs/platform-socket.io": "^10.3.9", "@nestjs/platform-socket.io": "^10.4.1",
"@nestjs/terminus": "^10.2.3", "@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^10.3.9", "@nestjs/websockets": "^10.4.1",
"@react-email/components": "0.0.19", "@react-email/components": "0.0.24",
"@react-email/render": "^0.0.15", "@react-email/render": "^1.0.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"@types/pg": "^8.11.6",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.8.2", "bullmq": "^5.12.12",
"bytes": "^3.1.2", "bytes": "^3.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"fastify": "^4.28.0",
"fix-esm": "^1.0.1", "fix-esm": "^1.0.1",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"happy-dom": "^14.12.3", "happy-dom": "^15.7.3",
"kysely": "^0.27.3", "kysely": "^0.27.4",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"marked": "^13.0.2", "marked": "^13.0.3",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nestjs-kysely": "^1.0.0", "nestjs-kysely": "^1.0.0",
@ -69,46 +67,47 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"postmark": "^4.0.4", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"redis": "^4.6.14", "redis": "^4.7.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2", "sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"ws": "^8.17.1" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.3.2", "@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.1", "@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.3.9", "@nestjs/testing": "^10.4.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/bytes": "^3.1.4", "@types/bytes": "^3.1.4",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.14.9", "@types/node": "^22.5.2",
"@types/nodemailer": "^6.4.15", "@types/nodemailer": "^6.4.15",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.8",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^7.14.1", "@typescript-eslint/parser": "^8.3.0",
"eslint": "^9.5.0", "eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"kysely-codegen": "^0.15.0", "kysely-codegen": "^0.16.3",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"react-email": "^2.1.4", "react-email": "^3.0.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.1.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.5.2" "typescript": "^5.5.4"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@ -10,28 +10,38 @@ import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header';
import { import {
Callout, Callout,
Comment, Comment,
CustomCodeBlock,
Details, Details,
DetailsContent, DetailsContent,
DetailsSummary, DetailsSummary,
LinkExtension, LinkExtension,
MathBlock, MathBlock,
MathInline, MathInline,
Table,
TableCell, TableCell,
TableHeader,
TableRow, TableRow,
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
TrailingNode, TrailingNode,
Attachment,
Drawio,
Excalidraw,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, JSONContent } from '@tiptap/core'; import { generateText, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
// @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
import { generateJSON } from '@tiptap/html';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit, StarterKit.configure({
codeBlock: false,
}),
Comment, Comment,
TextAlign, TextAlign,
TaskList, TaskList,
@ -58,6 +68,10 @@ export const tiptapExtensions = [
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
Callout, Callout,
Attachment,
CustomCodeBlock,
Drawio,
Excalidraw,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {

View File

@ -10,10 +10,7 @@ export function generateJSON(
const schema = getSchema(extensions); const schema = getSchema(extensions);
const window = new Window(); const window = new Window();
const dom = new HappyDomParser(window).parseFromString( const dom = new HappyDomParser().parseFromString(html, 'text/html').body;
html,
'text/html',
).body;
// @ts-ignore // @ts-ignore
return DOMParser.fromSchema(schema).parse(dom, options).toJSON(); return DOMParser.fromSchema(schema).parse(dom, options).toJSON();

View File

@ -5,10 +5,10 @@ export enum AttachmentType {
File = 'file', File = 'file',
} }
export const validImageExtensions = ['.jpg', '.png', '.jpeg', 'gif']; export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '5MB'; export const MAX_AVATAR_SIZE = '5MB';
export const InlineFileExtensions = [ export const inlineFileExtensions = [
'.jpg', '.jpg',
'.png', '.png',
'.jpeg', '.jpeg',
@ -16,4 +16,4 @@ export const InlineFileExtensions = [
'.mp4', '.mp4',
'.mov', '.mov',
]; ];
export const MAX_FILE_SIZE = '20MB'; export const MAX_FILE_SIZE = '50MB';

View File

@ -30,6 +30,7 @@ import {
import { getMimeType } from '../../common/helpers'; import { getMimeType } from '../../common/helpers';
import { import {
AttachmentType, AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE, MAX_AVATAR_SIZE,
MAX_FILE_SIZE, MAX_FILE_SIZE,
} from './attachment.constants'; } from './attachment.constants';
@ -75,7 +76,7 @@ export class AttachmentController {
let file = null; let file = null;
try { try {
file = await req.file({ file = await req.file({
limits: { fileSize: maxFileSize, fields: 2, files: 1 }, limits: { fileSize: maxFileSize, fields: 3, files: 1 },
}); });
} catch (err: any) { } catch (err: any) {
this.logger.error(err.message); this.logger.error(err.message);
@ -112,6 +113,11 @@ export class AttachmentController {
const spaceId = page.spaceId; const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try { try {
const fileResponse = await this.attachmentService.uploadFile({ const fileResponse = await this.attachmentService.uploadFile({
filePromise: file, filePromise: file,
@ -119,10 +125,16 @@ export class AttachmentController {
spaceId: spaceId, spaceId: spaceId,
userId: user.id, userId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
attachmentId: attachmentId,
}); });
return res.send(fileResponse); return res.send(fileResponse);
} catch (err: any) { } 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); this.logger.error(err);
throw new BadRequestException('Error processing file upload.'); throw new BadRequestException('Error processing file upload.');
} }
@ -163,9 +175,17 @@ export class AttachmentController {
try { try {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.read(attachment.filePath);
res.headers({ res.headers({
'Content-Type': getMimeType(attachment.filePath), 'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600', 'Cache-Control': 'public, max-age=3600',
}); });
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream); return res.send(fileStream);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);

View File

@ -38,7 +38,6 @@ export async function prepareFile(
mimeType: file.mimetype, mimeType: file.mimetype,
}; };
} catch (error) { } catch (error) {
console.error('Error in file preparation:', error);
throw error; throw error;
} }
} }

View File

@ -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 { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import { import {
@ -36,17 +41,53 @@ export class AttachmentService {
userId: string; userId: string;
spaceId: string; spaceId: string;
workspaceId: string; workspaceId: string;
attachmentId?: string;
}) { }) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts; const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise); 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}`; const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
await this.uploadToDrive(filePath, preparedFile.buffer); await this.uploadToDrive(filePath, preparedFile.buffer);
let attachment: Attachment = null; let attachment: Attachment = null;
try { try {
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
updatedAt: new Date(),
},
attachmentId,
);
} else {
attachment = await this.saveAttachment({ attachment = await this.saveAttachment({
attachmentId, attachmentId,
preparedFile, preparedFile,
@ -57,9 +98,10 @@ export class AttachmentService {
workspaceId, workspaceId,
pageId, pageId,
}); });
}
} catch (err) { } catch (err) {
// delete uploaded file on error // delete uploaded file on error
console.error(err); this.logger.error(err);
} }
return attachment; return attachment;

View File

@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@ -217,11 +218,21 @@ export class WorkspaceService {
) { ) {
const user = await this.userRepo.findById(userRoleDto.userId, workspaceId); const user = await this.userRepo.findById(userRoleDto.userId, workspaceId);
const newRole = userRoleDto.role.toLowerCase();
if (!user) { if (!user) {
throw new BadRequestException('Workspace member not found'); throw new BadRequestException('Workspace member not found');
} }
if (user.role === userRoleDto.role) { // prevent ADMIN from managing OWNER role
if (
(authUser.role === UserRole.ADMIN && newRole === UserRole.OWNER) ||
(authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER)
) {
throw new ForbiddenException();
}
if (user.role === newRole) {
return user; return user;
} }
@ -238,7 +249,7 @@ export class WorkspaceService {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
role: userRoleDto.role, role: newRole,
}, },
user.id, user.id,
workspaceId, workspaceId,

View File

@ -43,8 +43,8 @@ export class AttachmentRepo {
async updateAttachment( async updateAttachment(
updatableAttachment: UpdatableAttachment, updatableAttachment: UpdatableAttachment,
attachmentId: string, attachmentId: string,
): Promise<void> { ): Promise<Attachment> {
await this.db return await this.db
.updateTable('attachments') .updateTable('attachments')
.set(updatableAttachment) .set(updatableAttachment)
.where('id', '=', attachmentId) .where('id', '=', attachmentId)

View File

@ -63,6 +63,10 @@ export class EnvironmentService {
return this.configService.get<string>('AWS_S3_ENDPOINT'); return this.configService.get<string>('AWS_S3_ENDPOINT');
} }
getAwsS3ForcePathStyle(): boolean {
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE')
}
getAwsS3Url(): string { getAwsS3Url(): string {
return this.configService.get<string>('AWS_S3_URL'); return this.configService.get<string>('AWS_S3_URL');
} }

View File

@ -61,7 +61,8 @@ export class ImportController {
res.headers({ res.headers({
'Content-Type': getMimeType(fileExt), 'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'attachment; filename="' + fileName + '"', 'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(rawContent); res.send(rawContent);

View File

@ -18,11 +18,11 @@ export function turndown(html: string): string {
highlightedCodeBlock, highlightedCodeBlock,
taskList, taskList,
callout, callout,
toggleListTitle, preserveDetail,
toggleListBody,
listParagraph, listParagraph,
mathInline,
mathBlock,
]); ]);
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
} }
@ -72,29 +72,51 @@ function taskList(turndownService: TurndownService) {
}); });
} }
function toggleListTitle(turndownService: TurndownService) { function preserveDetail(turndownService: TurndownService) {
turndownService.addRule('toggleListTitle', { turndownService.addRule('preserveDetail', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return node.nodeName === 'DETAILS';
node.nodeName === 'SUMMARY' && node.parentNode.nodeName === 'DETAILS'
);
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: any, node: HTMLInputElement) {
return '- ' + content; // TODO: preserve summary of nested details
const summary = node.querySelector(':scope > summary');
let detailSummary = '';
if (summary) {
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
summary.remove();
}
const detailsContent = turndownService.turndown(node.innerHTML);
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
}, },
}); });
} }
function toggleListBody(turndownService: TurndownService) { function mathInline(turndownService: TurndownService) {
turndownService.addRule('toggleListContent', { turndownService.addRule('mathInline', {
filter: function (node: HTMLInputElement) { filter: function (node: HTMLInputElement) {
return ( return (
node.getAttribute('data-type') === 'detailsContent' && node.nodeName === 'SPAN' &&
node.parentNode.nodeName === 'DETAILS' node.getAttribute('data-type') === 'mathInline'
); );
}, },
replacement: function (content: any, node: HTMLInputElement) { replacement: function (content: any, node: HTMLInputElement) {
return ` ${content.replace(/\n/g, '\n ')} `; return `$${content}$`;
},
});
}
function mathBlock(turndownService: TurndownService) {
turndownService.addRule('mathBlock', {
filter: function (node: HTMLInputElement) {
return (
node.nodeName === 'DIV' &&
node.getAttribute('data-type') === 'mathBlock'
);
},
replacement: function (content: any, node: HTMLInputElement) {
return `\n$$${content}$$\n`;
}, },
}); });
} }

View File

@ -20,6 +20,7 @@ import {
} from '../../core/casl/interfaces/space-ability.type'; } from '../../core/casl/interfaces/space-ability.type';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import * as path from 'path';
import { MAX_FILE_SIZE } from '../../core/attachment/attachment.constants'; import { MAX_FILE_SIZE } from '../../core/attachment/attachment.constants';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
@ -42,6 +43,8 @@ export class ImportController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const validFileExtensions = ['.md', '.html'];
const maxFileSize = bytes(MAX_FILE_SIZE); const maxFileSize = bytes(MAX_FILE_SIZE);
let file = null; let file = null;
@ -62,6 +65,12 @@ export class ImportController {
throw new BadRequestException('Failed to upload file'); throw new BadRequestException('Failed to upload file');
} }
if (
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
) {
throw new BadRequestException('Invalid import file type.');
}
const spaceId = file.fields?.spaceId?.value; const spaceId = file.fields?.spaceId?.value;
if (!spaceId) { if (!spaceId) {

View File

@ -3,13 +3,17 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { MultipartFile } from '@fastify/multipart'; import { MultipartFile } from '@fastify/multipart';
import { sanitize } from 'sanitize-filename-ts'; import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path'; import * as path from 'path';
import { htmlToJson } from '../../collaboration/collaboration.util'; import {
import { marked } from 'marked'; htmlToJson,
tiptapExtensions,
} from '../../collaboration/collaboration.util';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateSlugId } from '../../common/helpers'; import { generateSlugId } from '../../common/helpers';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { transformHTML } from './utils/html.utils'; import { markdownToHtml } from './utils/marked.utils';
import { TiptapTransformer } from '@hocuspocus/transformer';
import * as Y from 'yjs';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
@ -30,22 +34,25 @@ export class ImportService {
const fileBuffer = await file.toBuffer(); const fileBuffer = await file.toBuffer();
const fileName = sanitize(file.filename).slice(0, 255).split('.')[0]; const fileName = sanitize(file.filename).slice(0, 255).split('.')[0];
const fileExtension = path.extname(file.filename).toLowerCase(); const fileExtension = path.extname(file.filename).toLowerCase();
const fileMimeType = file.mimetype;
const fileContent = fileBuffer.toString(); const fileContent = fileBuffer.toString();
let prosemirrorState = null; let prosemirrorState = null;
let createdPage = null; let createdPage = null;
if (fileExtension.endsWith('.md') && fileMimeType === 'text/markdown') { try {
if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent); prosemirrorState = await this.processMarkdown(fileContent);
} } else if (fileExtension.endsWith('.html')) {
if (fileExtension.endsWith('.html') && fileMimeType === 'text/html') {
prosemirrorState = await this.processHTML(fileContent); prosemirrorState = await this.processHTML(fileContent);
} }
} catch (err) {
const message = 'Error processing file content';
this.logger.error(message, err);
throw new BadRequestException(message);
}
if (!prosemirrorState) { if (!prosemirrorState) {
const message = 'Unsupported file format or mime type'; const message = 'Failed to create ProseMirror state';
this.logger.error(message); this.logger.error(message);
throw new BadRequestException(message); throw new BadRequestException(message);
} }
@ -63,14 +70,19 @@ export class ImportService {
slugId: generateSlugId(), slugId: generateSlugId(),
title: pageTitle, title: pageTitle,
content: prosemirrorJson, content: prosemirrorJson,
ydoc: await this.createYdoc(prosemirrorJson),
position: pagePosition, position: pagePosition,
spaceId: spaceId, spaceId: spaceId,
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
lastUpdatedById: userId, lastUpdatedById: userId,
}); });
this.logger.debug(
`Successfully imported "${title}${fileExtension}. ID: ${createdPage.id} - SlugId: ${createdPage.slugId}"`,
);
} catch (err) { } catch (err) {
const message = 'Failed to create page'; const message = 'Failed to create imported page';
this.logger.error(message, err); this.logger.error(message, err);
throw new BadRequestException(message); throw new BadRequestException(message);
} }
@ -80,14 +92,37 @@ export class ImportService {
} }
async processMarkdown(markdownInput: string): Promise<any> { async processMarkdown(markdownInput: string): Promise<any> {
// turn markdown to html try {
const html = await marked.parse(markdownInput); const html = await markdownToHtml(markdownInput);
return await this.processHTML(html); return this.processHTML(html);
} catch (err) {
throw err;
}
} }
async processHTML(htmlInput: string): Promise<any> { async processHTML(htmlInput: string): Promise<any> {
// turn html to prosemirror state try {
return htmlToJson(transformHTML(htmlInput)); return htmlToJson(htmlInput);
} catch (err) {
throw err;
}
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
this.logger.debug(`Converting prosemirror json state to ydoc`);
const ydoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return Buffer.from(Y.encodeStateAsUpdate(ydoc));
}
return null;
} }
extractTitleAndRemoveHeading(prosemirrorState: any) { extractTitleAndRemoveHeading(prosemirrorState: any) {

View File

@ -1,80 +0,0 @@
import { Window, DOMParser } from 'happy-dom';
function transformTaskList(html: string): string {
const window = new Window();
const doc = new DOMParser(window).parseFromString(html, 'text/html');
const ulElements = doc.querySelectorAll('ul');
ulElements.forEach((ul) => {
let isTaskList = false;
const liElements = ul.querySelectorAll('li');
liElements.forEach((li) => {
const checkbox = li.querySelector('input[type="checkbox"]');
if (checkbox) {
isTaskList = true;
// Add taskItem data type
li.setAttribute('data-type', 'taskItem');
// Set data-checked attribute based on the checkbox state
// @ts-ignore
li.setAttribute('data-checked', checkbox.checked ? 'true' : 'false');
// Remove the checkbox from the li
checkbox.remove();
// Move the content of <p> out of the <p> and remove <p>
const pElements = li.querySelectorAll('p');
pElements.forEach((p) => {
// Append the content of the <p> element to its parent (the <li> element)
while (p.firstChild) {
li.appendChild(p.firstChild);
}
// Remove the now empty <p> element
p.remove();
});
}
});
// If any <li> contains a checkbox, mark the <ul> as a task list
if (isTaskList) {
ul.setAttribute('data-type', 'taskList');
}
});
return doc.body.innerHTML;
}
function transformCallouts(html: string): string {
const window = new Window();
const doc = new DOMParser(window).parseFromString(html, 'text/html');
const calloutRegex = /:::(\w+)\s*([\s\S]*?)\s*:::/g;
const createCalloutDiv = (type: string, content: string): HTMLElement => {
const div = doc.createElement('div');
div.setAttribute('data-type', 'callout');
div.setAttribute('data-callout-type', type);
const p = doc.createElement('p');
p.textContent = content.trim();
div.appendChild(p);
return div as unknown as HTMLElement;
};
const pElements = doc.querySelectorAll('p');
pElements.forEach((p) => {
if (calloutRegex.test(p.innerHTML) && !p.closest('ul, ol')) {
calloutRegex.lastIndex = 0;
const [, type, content] = calloutRegex.exec(p.innerHTML) || [];
const calloutDiv = createCalloutDiv(type, content);
// @ts-ignore
p.replaceWith(calloutDiv);
}
});
return doc.body.innerHTML;
}
export function transformHTML(html: string): string {
return transformTaskList(transformCallouts(html));
}

View File

@ -0,0 +1,36 @@
import { marked } from 'marked';
marked.use({
renderer: {
// @ts-ignore
list(body: string, isOrdered: boolean, start: number) {
if (isOrdered) {
const startAttr = start !== 1 ? ` start="${start}"` : '';
return `<ol ${startAttr}>\n${body}</ol>\n`;
}
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : '';
return `<ul${dataType}>\n${body}</ul>\n`;
},
// @ts-ignore
listitem({ text, raw, task: isTask, checked: isChecked }): string {
if (!isTask) {
return `<li>${text}</li>\n`;
}
const checkedAttr = isChecked
? 'data-checked="true"'
: 'data-checked="false"';
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
},
},
});
export async function markdownToHtml(markdownInput: string): Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
const markdown = markdownInput
.replace(YAML_FONT_MATTER_REGEX, '')
.trimStart();
return marked.parse(markdown);
}

View File

@ -19,8 +19,8 @@ export class MailService {
async sendEmail(message: MailMessage): Promise<void> { async sendEmail(message: MailMessage): Promise<void> {
if (message.template) { if (message.template) {
// in case this method is used directly. we do not send the tsx template from queue // in case this method is used directly. we do not send the tsx template from queue
message.html = render(message.template, { pretty: true }); message.html = await render(message.template, { pretty: true });
message.text = render(message.template, { plainText: true }); message.text = await render(message.template, { plainText: true });
} }
const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `; const sender = `${this.environmentService.getMailFromName()} <${this.environmentService.getMailFromAddress()}> `;
@ -30,8 +30,8 @@ export class MailService {
async sendToQueue(message: MailMessage): Promise<void> { async sendToQueue(message: MailMessage): Promise<void> {
if (message.template) { if (message.template) {
// transform the React object because it gets lost when sent via the queue // transform the React object because it gets lost when sent via the queue
message.html = render(message.template, { pretty: true }); message.html = await render(message.template, { pretty: true });
message.text = render(message.template, { message.text = await render(message.template, {
plainText: true, plainText: true,
}); });
delete message.template; delete message.template;

View File

@ -48,6 +48,7 @@ export const storageDriverConfigProvider = {
endpoint: environmentService.getAwsS3Endpoint(), endpoint: environmentService.getAwsS3Endpoint(),
bucket: environmentService.getAwsS3Bucket(), bucket: environmentService.getAwsS3Bucket(),
baseUrl: environmentService.getAwsS3Url(), baseUrl: environmentService.getAwsS3Url(),
forcePathStyle: environmentService.getAwsS3ForcePathStyle(),
credentials: undefined, credentials: undefined,
}, },
}; };

View File

@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.2.7", "version": "0.3.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",
@ -24,39 +24,41 @@
"@joplin/turndown": "^4.0.74", "@joplin/turndown": "^4.0.74",
"@joplin/turndown-plugin-gfm": "^1.0.56", "@joplin/turndown-plugin-gfm": "^1.0.56",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tiptap/core": "^2.5.4", "@tiptap/core": "^2.6.6",
"@tiptap/extension-code-block": "^2.5.4", "@tiptap/extension-code-block": "^2.6.6",
"@tiptap/extension-collaboration": "^2.5.4", "@tiptap/extension-code-block-lowlight": "^2.6.6",
"@tiptap/extension-collaboration-cursor": "^2.5.4", "@tiptap/extension-collaboration": "^2.6.6",
"@tiptap/extension-color": "^2.5.4", "@tiptap/extension-collaboration-cursor": "^2.6.6",
"@tiptap/extension-document": "^2.5.4", "@tiptap/extension-color": "^2.6.6",
"@tiptap/extension-heading": "^2.5.4", "@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-highlight": "^2.5.4", "@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.5.4", "@tiptap/extension-highlight": "^2.6.6",
"@tiptap/extension-image": "^2.5.4", "@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-link": "^2.5.4", "@tiptap/extension-image": "^2.6.6",
"@tiptap/extension-list-item": "^2.5.4", "@tiptap/extension-link": "^2.6.6",
"@tiptap/extension-list-keymap": "^2.5.4", "@tiptap/extension-list-item": "^2.6.6",
"@tiptap/extension-mention": "^2.5.4", "@tiptap/extension-list-keymap": "^2.6.6",
"@tiptap/extension-placeholder": "^2.5.4", "@tiptap/extension-mention": "^2.6.6",
"@tiptap/extension-subscript": "^2.5.4", "@tiptap/extension-placeholder": "^2.6.6",
"@tiptap/extension-superscript": "^2.5.4", "@tiptap/extension-subscript": "^2.6.6",
"@tiptap/extension-table": "^2.5.4", "@tiptap/extension-superscript": "^2.6.6",
"@tiptap/extension-table-cell": "^2.5.4", "@tiptap/extension-table": "^2.6.6",
"@tiptap/extension-table-header": "^2.5.4", "@tiptap/extension-table-cell": "^2.6.6",
"@tiptap/extension-table-row": "^2.5.4", "@tiptap/extension-table-header": "^2.6.6",
"@tiptap/extension-task-item": "^2.5.4", "@tiptap/extension-table-row": "^2.6.6",
"@tiptap/extension-task-list": "^2.5.4", "@tiptap/extension-task-item": "^2.6.6",
"@tiptap/extension-text": "^2.5.4", "@tiptap/extension-task-list": "^2.6.6",
"@tiptap/extension-text-align": "^2.5.4", "@tiptap/extension-text": "^2.6.6",
"@tiptap/extension-text-style": "^2.5.4", "@tiptap/extension-text-align": "^2.6.6",
"@tiptap/extension-typography": "^2.5.4", "@tiptap/extension-text-style": "^2.6.6",
"@tiptap/extension-underline": "^2.5.4", "@tiptap/extension-typography": "^2.6.6",
"@tiptap/extension-youtube": "^2.5.4", "@tiptap/extension-underline": "^2.6.6",
"@tiptap/pm": "^2.5.4", "@tiptap/extension-youtube": "^2.6.6",
"@tiptap/react": "^2.5.4", "@tiptap/html": "^2.6.6",
"@tiptap/starter-kit": "^2.5.4", "@tiptap/pm": "^2.6.6",
"@tiptap/suggestion": "^2.5.4", "@tiptap/react": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@tiptap/suggestion": "^2.6.6",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"fractional-indexing-jittered": "^0.9.1", "fractional-indexing-jittered": "^0.9.1",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
@ -65,11 +67,11 @@
"yjs": "^13.6.18" "yjs": "^13.6.18"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "19.3.2", "@nx/js": "19.6.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"nx": "19.3.2", "nx": "19.6.3",
"tsx": "^4.15.7" "tsx": "^4.19.0"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [

View File

@ -8,6 +8,5 @@
}, },
"main": "dist/index.js", "main": "dist/index.js",
"module": "./src/index.ts", "module": "./src/index.ts",
"types": "dist/index.d.ts", "types": "dist/index.d.ts"
"dependencies": {}
} }

View File

@ -10,3 +10,8 @@ export * from "./lib/callout";
export * from "./lib/media-utils"; export * from "./lib/media-utils";
export * from "./lib/link"; export * from "./lib/link";
export * from "./lib/selection"; export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/custom-code-block"
export * from "./lib/drawio";
export * from "./lib/excalidraw";

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

View 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",
}),
];
},
});

View File

@ -0,0 +1,2 @@
export { Attachment } from "./attachment";
export * from "./attachment-upload";

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

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

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

View File

@ -1,7 +1,7 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { IAttachment } from "client/src/lib/types";
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
const uploadKey = new PluginKey("image-upload"); const uploadKey = new PluginKey("image-upload");

View File

@ -36,7 +36,9 @@ export const MathBlock = Node.create({
return { return {
text: { text: {
default: "", default: "",
parseHTML: (element) => element.innerHTML.split("$")[1], parseHTML: (element) => {
return element.innerHTML;
},
}, },
}; };
}, },
@ -44,7 +46,7 @@ export const MathBlock = Node.create({
parseHTML() { parseHTML() {
return [ return [
{ {
tag: "div", tag: `div[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => { getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false; return node.hasAttribute("data-katex") ? {} : false;
}, },
@ -55,8 +57,8 @@ export const MathBlock = Node.create({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
"div", "div",
{}, { "data-type": this.name, "data-katex": true },
["div", { "data-katex": true }, `$$${HTMLAttributes.text}$$`], `${HTMLAttributes.text}`,
]; ];
}, },

View File

@ -37,7 +37,9 @@ export const MathInline = Node.create<MathInlineOption>({
return { return {
text: { text: {
default: "", default: "",
parseHTML: (element) => element.innerHTML.split("$")[1], parseHTML: (element) => {
return element.innerHTML;
},
}, },
}; };
}, },
@ -45,7 +47,7 @@ export const MathInline = Node.create<MathInlineOption>({
parseHTML() { parseHTML() {
return [ return [
{ {
tag: "span", tag: `span[data-type="${this.name}"]`,
getAttrs: (node: HTMLElement) => { getAttrs: (node: HTMLElement) => {
return node.hasAttribute("data-katex") ? {} : false; return node.hasAttribute("data-katex") ? {} : false;
}, },
@ -54,7 +56,11 @@ export const MathInline = Node.create<MathInlineOption>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return ["span", { "data-katex": true }, `$${HTMLAttributes.text}$` || {}]; return [
"span",
{ "data-type": this.name, "data-katex": true },
`${HTMLAttributes.text}`,
];
}, },
addNodeView() { addNodeView() {

View File

@ -1,3 +0,0 @@
import TiptapTableHeader from "@tiptap/extension-table-header";
export const TableHeader = TiptapTableHeader.configure();

View File

@ -1,4 +1,2 @@
export * from "./table-extension";
export * from "./header";
export * from "./row"; export * from "./row";
export * from "./cell"; export * from "./cell";

View File

@ -1,7 +0,0 @@
import TiptapTable from "@tiptap/extension-table";
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
});

View File

@ -0,0 +1,17 @@
// repetition for now
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}

View File

@ -3,7 +3,7 @@ import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
import { Selection, Transaction } from "@tiptap/pm/state"; import { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables"; import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Node, ResolvedPos } from "@tiptap/pm/model"; import { Node, ResolvedPos } from "@tiptap/pm/model";
import { Table } from "./table/table-extension"; import Table from "@tiptap/extension-table";
export const isRectSelected = (rect: any) => (selection: CellSelection) => { export const isRectSelected = (rect: any) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1)); const map = TableMap.get(selection.$anchorCell.node(-1));

View File

@ -1,7 +1,7 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { IAttachment } from "client/src/lib/types";
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
const uploadKey = new PluginKey("video-upload"); const uploadKey = new PluginKey("video-upload");

6694
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff