mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 05:31:15 +10:00
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
This commit is contained in:
@ -1,12 +1,13 @@
|
||||
import React, { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Slider } from "@mantine/core";
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { Slider } from '@mantine/core';
|
||||
|
||||
export type ImageWidthProps = {
|
||||
onChange: (value: number) => void;
|
||||
value: number;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
|
||||
export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
@ -17,16 +18,17 @@ export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
|
||||
(newValue: number) => {
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange],
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
p={"sm"}
|
||||
p={'sm'}
|
||||
min={10}
|
||||
value={currentValue}
|
||||
onChange={setCurrentValue}
|
||||
onChangeEnd={handleChange}
|
||||
w={width || 100}
|
||||
label={(value) => `${value}%`}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
BubbleMenu as BaseBubbleMenu,
|
||||
findParentNode,
|
||||
posToDOMRect,
|
||||
} from '@tiptap/react';
|
||||
import { useCallback } from 'react';
|
||||
import { sticky } from 'tippy.js';
|
||||
import { Node as PMNode } from 'prosemirror-model';
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from '@/features/editor/components/table/types/types.ts';
|
||||
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
|
||||
|
||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const getReferenceClientRect = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
return dom.getBoundingClientRect();
|
||||
}
|
||||
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu}`}
|
||||
updateDelay={0}
|
||||
tippyOptions={{
|
||||
getReferenceClientRect,
|
||||
offset: [0, 8],
|
||||
zIndex: 99,
|
||||
popperOptions: {
|
||||
modifiers: [{ name: 'flip', enabled: false }],
|
||||
},
|
||||
plugins: [sticky],
|
||||
sticky: 'popper',
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editor.getAttributes('excalidraw')?.width && (
|
||||
<NodeWidthResize
|
||||
onChange={onWidthChange}
|
||||
value={parseInt(editor.getAttributes('excalidraw').width)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExcalidrawMenu;
|
||||
@ -0,0 +1,195 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { Excalidraw, exportToSvg, loadFromBlob } from '@excalidraw/excalidraw';
|
||||
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';
|
||||
|
||||
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 {
|
||||
let data = null;
|
||||
if (src) {
|
||||
const url = getFileUrl(src);
|
||||
const request = await fetch(url, { credentials: 'include' });
|
||||
|
||||
data = await loadFromBlob(await request.blob(), null, null);
|
||||
}
|
||||
|
||||
setExcalidrawData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
open();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
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' }}>
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -25,6 +25,8 @@ import {
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
|
||||
const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
basic: [
|
||||
@ -292,8 +294,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
{
|
||||
title: "Mermaid diagram",
|
||||
description: "Insert mermaid diagram",
|
||||
searchTerms: ["mermaid", "diagram", "chart"],
|
||||
icon: IconLetterY,
|
||||
searchTerms: ["mermaid", "diagrams", "chart", "uml"],
|
||||
icon: IconMermaid,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor
|
||||
.chain()
|
||||
@ -302,6 +304,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
.setCodeBlock({ language: "mermaid" })
|
||||
.run(),
|
||||
},
|
||||
{
|
||||
title: "Excalidraw diagram",
|
||||
description: "Draw and sketch excalidraw diagrams",
|
||||
searchTerms: ["diagrams", "draw", "sketch"],
|
||||
icon: IconExcalidraw,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setExcalidraw().run(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
Selection,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
Excalidraw,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@ -49,6 +50,7 @@ import { common, createLowlight } from "lowlight";
|
||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
@ -81,6 +83,7 @@ export const mainExtensions = [
|
||||
}
|
||||
},
|
||||
includeChildren: true,
|
||||
showOnlyWhenEditable: true,
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TaskList,
|
||||
@ -151,6 +154,9 @@ export const mainExtensions = [
|
||||
Attachment.configure({
|
||||
view: AttachmentView,
|
||||
}),
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@ -37,6 +37,7 @@ import {
|
||||
handleFilePaste,
|
||||
} from "@/features/editor/components/common/file-upload-handler.tsx";
|
||||
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -171,6 +172,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -9,6 +9,3 @@
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.node-image, .node-video {
|
||||
.node-image, .node-video, .node-excalidraw{
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
.ProseMirror table .is-editor-empty:first-child::before,
|
||||
.ProseMirror table .is-empty::before {
|
||||
content: '';
|
||||
display: none;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user