mirror of
https://github.com/docmost/docmost.git
synced 2026-07-20 18:22:47 +10:00
Merge branch 'main'
This commit is contained in:
@@ -1,17 +1,43 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
||||
import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { editor, node, getPos, selected } = props;
|
||||
const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
|
||||
const { hovered, ref } = useHover();
|
||||
|
||||
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
|
||||
|
||||
const handleEmbedAsPdf = useCallback(() => {
|
||||
const pos = getPos();
|
||||
if (pos === undefined || !url) return;
|
||||
|
||||
const nodeSize = node.nodeSize;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(
|
||||
{ from: pos, to: pos + nodeSize },
|
||||
{
|
||||
type: "pdf",
|
||||
attrs: {
|
||||
src: url,
|
||||
name,
|
||||
attachmentId,
|
||||
size,
|
||||
},
|
||||
},
|
||||
)
|
||||
.run();
|
||||
}, [editor, getPos, node, url, name, attachmentId]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||
@@ -23,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
h={25}
|
||||
>
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
{!url && placeholder ? (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
{!url && placeholder ? t("Uploading {{name}}", { name }) : name}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
</Group>
|
||||
|
||||
{url && (selected || hovered) && (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isPdf && editor.isEditable && (
|
||||
<Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
|
||||
<ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
|
||||
<IconFileTypePdf size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function AudioMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioAttrs = ctx.editor.getAttributes("audio");
|
||||
|
||||
return {
|
||||
isAudio: ctx.editor.isActive("audio"),
|
||||
src: audioAttrs?.src || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("audio") && editor.getAttributes("audio").src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "audio";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
}, [editorState?.src]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`audio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
aria-label={t("Download")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioMenu;
|
||||
@@ -0,0 +1,37 @@
|
||||
.audioWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -135% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { isInternalFileUrl } from "@docmost/editor-ext";
|
||||
import classes from "./audio-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AudioView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node } = props;
|
||||
const { src, placeholder } = node.attrs;
|
||||
|
||||
const safeSrc = useMemo(() => {
|
||||
if (!src || !isInternalFileUrl(src)) return null;
|
||||
return getFileUrl(src);
|
||||
}, [src]);
|
||||
|
||||
const previewSrc = useMemo(() => {
|
||||
editor.storage.shared.audioPreviews =
|
||||
editor.storage.shared.audioPreviews || {};
|
||||
|
||||
if (placeholder?.id) {
|
||||
return editor.storage.shared.audioPreviews[placeholder.id];
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [placeholder, editor]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={`${classes.audioWrapper} ${!safeSrc && placeholder ? classes.skeleton : ''}`}>
|
||||
{safeSrc && (
|
||||
<audio
|
||||
className={classes.audio}
|
||||
preload="metadata"
|
||||
controls
|
||||
src={safeSrc}
|
||||
/>
|
||||
)}
|
||||
{!safeSrc && previewSrc && (
|
||||
<Group pos="relative" w="100%">
|
||||
<audio
|
||||
className={classes.audio}
|
||||
preload="metadata"
|
||||
controls
|
||||
src={previewSrc}
|
||||
/>
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!safeSrc && !previewSrc && placeholder && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!safeSrc && !previewSrc && !placeholder && (
|
||||
<audio className={classes.audio} controls />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { handleAudioUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||
import { formatBytes } from "@/lib";
|
||||
import i18n from "@/i18n.ts";
|
||||
|
||||
export const uploadAudioAction = handleAudioUpload({
|
||||
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("audio/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||
limit: formatBytes(getFileUploadSizeLimit()),
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { IconMessage } from "@tabler/icons-react";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
showReadOnlyCommentPopupAtom,
|
||||
readOnlyCommentDataAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
type ReadonlyBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
||||
showReadOnlyCommentPopupAtom,
|
||||
);
|
||||
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const isInteractingRef = useRef(false);
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isInteractingRef.current) return;
|
||||
|
||||
const pmSelection = editor.state.selection;
|
||||
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (
|
||||
!selection ||
|
||||
selection.isCollapsed ||
|
||||
selection.rangeCount === 0 ||
|
||||
showReadOnlyCommentPopup
|
||||
) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDom = editor.view.dom;
|
||||
if (
|
||||
!editorDom.contains(selection.anchorNode) ||
|
||||
!editorDom.contains(selection.focusNode)
|
||||
) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
if (rect.width === 0) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const editorRect = editorDom
|
||||
.closest(".editor-container")
|
||||
?.getBoundingClientRect();
|
||||
if (!editorRect) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.top - editorRect.top - 44,
|
||||
left: rect.left - editorRect.left + rect.width / 2,
|
||||
});
|
||||
setVisible(true);
|
||||
}, [editor, showReadOnlyCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
updateMenuPosition();
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
};
|
||||
}, [updateMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showReadOnlyCommentPopup) {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [showReadOnlyCommentPopup]);
|
||||
|
||||
const handleCommentClick = () => {
|
||||
if (!editor) return;
|
||||
|
||||
const view = editor.view;
|
||||
const ystate = ySyncPluginKey.getState(view.state);
|
||||
|
||||
if (ystate?.binding) {
|
||||
const selection = getRelativeSelection(ystate.binding, view.state);
|
||||
const { from, to } = editor.state.selection;
|
||||
const selectedText = editor.state.doc.textBetween(from, to);
|
||||
|
||||
// @ts-ignore
|
||||
setReadOnlyCommentData({
|
||||
yjsSelection: {
|
||||
anchor: selection.anchor,
|
||||
head: selection.head,
|
||||
},
|
||||
selectedText,
|
||||
});
|
||||
|
||||
setShowReadOnlyCommentPopup(true);
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 199,
|
||||
}}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t("Comment")}
|
||||
style={{ border: "none" }}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isInteractingRef.current = true;
|
||||
handleCommentClick();
|
||||
isInteractingRef.current = false;
|
||||
}}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { uploadPdfAction } from "../pdf/upload-pdf-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"pdf",
|
||||
"attachment",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
@@ -63,6 +66,7 @@ export const handlePaste = (
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadPdfAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
@@ -229,6 +233,7 @@ export const handleFileDrop = (
|
||||
|
||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||
import classes from "./node-resize.module.css";
|
||||
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
|
||||
|
||||
export function createResizeHandle(
|
||||
direction: ResizableNodeViewDirection,
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
.cornerHandle {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -42,13 +42,13 @@
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 28px;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
|
||||
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
|
||||
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragRef.current && wrapperRef.current) {
|
||||
widthRef.current = initialWidth;
|
||||
heightRef.current = initialHeight;
|
||||
wrapperRef.current.style.width = `${initialWidth}px`;
|
||||
wrapperRef.current.style.height = `${initialHeight}px`;
|
||||
}
|
||||
}, [initialWidth, initialHeight]);
|
||||
|
||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !wrapperRef.current) return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import {
|
||||
ActionIcon,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Text,
|
||||
Tooltip,
|
||||
@@ -46,6 +47,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -140,6 +143,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const svgString = decodeBase64ToSvgString(svgXml);
|
||||
@@ -167,6 +171,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
isDirtyRef.current = false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [editor, editorState?.attachmentId]);
|
||||
|
||||
@@ -196,6 +201,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const handleOpen = useCallback(async () => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const url = getFileUrl(editorState.src);
|
||||
const request = await fetch(url, {
|
||||
@@ -213,6 +219,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isDirtyRef.current = false;
|
||||
open();
|
||||
}
|
||||
@@ -307,6 +314,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
size="lg"
|
||||
aria-label={t("Edit")}
|
||||
variant="subtle"
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
@@ -339,7 +347,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Body>
|
||||
<Modal.Body pos="relative">
|
||||
<LoadingOverlay visible={isSaving} />
|
||||
<div style={{ height: "100vh" }}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
@@ -34,6 +35,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
@@ -47,6 +49,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const svgString = decodeBase64ToSvgString(svgXml);
|
||||
@@ -79,6 +82,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
isDirtyRef.current = false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +140,8 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Body>
|
||||
<Modal.Body pos="relative">
|
||||
<LoadingOverlay visible={isSaving} />
|
||||
<div style={{ height: "100vh" }}>
|
||||
<DrawIoEmbed
|
||||
ref={drawioRef}
|
||||
|
||||
@@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
{embedUrl ? (
|
||||
<div className={classes.embedContainer}>
|
||||
<ResizableWrapper
|
||||
initialWidth={nodeWidth || 640}
|
||||
initialHeight={nodeHeight || 480}
|
||||
initialWidth={nodeWidth || 800}
|
||||
initialHeight={nodeHeight || 600}
|
||||
minWidth={200}
|
||||
maxWidth={1200}
|
||||
minHeight={200}
|
||||
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
|
||||
allowFullScreen
|
||||
frameBorder="0"
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const lastFingerprintRef = useRef("");
|
||||
|
||||
@@ -153,6 +155,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const handleOpen = useCallback(async () => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const url = getFileUrl(editorState.src);
|
||||
const request = await fetch(url, {
|
||||
@@ -166,6 +169,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isDirtyRef.current = false;
|
||||
isInitialLoadRef.current = true;
|
||||
open();
|
||||
@@ -178,6 +182,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
@@ -223,6 +228,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
isDirtyRef.current = false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [editor, excalidrawAPI, editorState?.attachmentId]);
|
||||
|
||||
@@ -339,6 +345,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
size="lg"
|
||||
aria-label={t("Edit")}
|
||||
variant="subtle"
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
@@ -390,7 +397,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
|
||||
{t("Save & Exit")}
|
||||
</Button>
|
||||
<Button onClick={handleClose} color="red" size={"compact-sm"}>
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const lastFingerprintRef = useRef("");
|
||||
|
||||
@@ -70,6 +71,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
@@ -120,6 +122,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
isDirtyRef.current = false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
|
||||
|
||||
@@ -191,7 +194,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
|
||||
{t("Save & Exit")}
|
||||
</Button>
|
||||
<Button onClick={handleClose} color="red" size={"compact-sm"}>
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.imageWrapper,
|
||||
!src && placeholder && classes.skeleton,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
@@ -54,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" bottom={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && (
|
||||
{!src && !previewSrc && placeholder && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAtom } from "jotai";
|
||||
import { isTextSelected } from "@docmost/editor-ext";
|
||||
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel";
|
||||
import { normalizeUrl } from "@/features/editor/components/link/link-view";
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { Paper } from "@mantine/core";
|
||||
|
||||
|
||||
@@ -29,12 +29,7 @@ import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
|
||||
|
||||
export const normalizeUrl = (url: string): string => {
|
||||
if (!url) return url;
|
||||
if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url;
|
||||
return `https://${url}`;
|
||||
};
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
|
||||
const parseInternalLink = (
|
||||
href: string,
|
||||
|
||||
@@ -62,7 +62,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
query: props.query,
|
||||
includeUsers: true,
|
||||
includePages: true,
|
||||
spaceId: space.id,
|
||||
spaceId: space?.id,
|
||||
limit: props.query ? 10 : 5,
|
||||
preload: true,
|
||||
});
|
||||
@@ -294,6 +294,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
w={popupWidth}
|
||||
scrollbars={"y"}
|
||||
scrollbarSize={6}
|
||||
overscrollBehavior={"contain"}
|
||||
styles={{ content: { minWidth: 0 } }}
|
||||
>
|
||||
{renderItems?.map((item, index) => {
|
||||
|
||||
@@ -53,8 +53,8 @@ const mentionRenderItems = () => {
|
||||
const editorDom = props.editor?.view?.dom;
|
||||
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
|
||||
const dialogEl = editorDom?.closest("[data-comment-dialog]");
|
||||
const isInCommentContext = !!(asideEl || dialogEl);
|
||||
// const isInCommentContext = !!asideEl;
|
||||
const chatInput = editorDom?.closest("[data-chat-input]");
|
||||
const isInCommentContext = !!(asideEl || dialogEl || chatInput);
|
||||
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props: { ...props, isInCommentContext },
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconPaperclip,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function PdfMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
if (!ctx.editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pdfAttrs = ctx.editor.getAttributes("pdf");
|
||||
|
||||
return {
|
||||
isPdf: ctx.editor.isActive("pdf"),
|
||||
src: pdfAttrs?.src || null,
|
||||
name: pdfAttrs?.name || null,
|
||||
attachmentId: pdfAttrs?.attachmentId || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state || !editor.isActive("pdf")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { selection } = state;
|
||||
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
|
||||
if (!dom) return false;
|
||||
|
||||
return !!dom.querySelector("[data-pdf-error]");
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
const predicate = (node: PMNode) => node.type.name === "pdf";
|
||||
const parent = findParentNode(predicate)(selection);
|
||||
|
||||
if (parent) {
|
||||
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||
const domRect = dom.getBoundingClientRect();
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}
|
||||
|
||||
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||
return {
|
||||
getBoundingClientRect: () => domRect,
|
||||
getClientRects: () => [domRect],
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const handleConvertToAttachment = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
|
||||
const { selection } = editor.state;
|
||||
const { from } = selection;
|
||||
const node = editor.state.doc.nodeAt(from);
|
||||
if (!node || node.type.name !== "pdf") return;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(
|
||||
{ from, to: from + node.nodeSize },
|
||||
{
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
url: node.attrs.src,
|
||||
name: node.attrs.name,
|
||||
attachmentId: node.attrs.attachmentId,
|
||||
size: node.attrs.size,
|
||||
mime: "application/pdf",
|
||||
},
|
||||
},
|
||||
)
|
||||
.run();
|
||||
}, [editor, editorState]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`pdf-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Convert to attachment")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleConvertToAttachment}
|
||||
size="lg"
|
||||
aria-label={t("Convert to attachment")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconPaperclip size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
aria-label={t("Delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default PdfMenu;
|
||||
@@ -0,0 +1,100 @@
|
||||
.pdfWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -135% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pdfContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdfResizeWrapper {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.pdfIframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hoverMenu {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hoverMenu::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
}
|
||||
|
||||
.hoverMenu:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pdfResizeWrapper:hover .hoverMenu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pdfError {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||
import clsx from "clsx";
|
||||
import classes from "./pdf-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isInternalFileUrl } from "@docmost/editor-ext";
|
||||
import {
|
||||
IconFileTypePdf,
|
||||
IconPaperclip,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export default function PdfView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { editor, node, getPos, selected, updateAttributes } = props;
|
||||
const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const safeSrc = useMemo(() => {
|
||||
if (!src || !isInternalFileUrl(src)) return null;
|
||||
return getFileUrl(src);
|
||||
}, [src]);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
const pos = getPos();
|
||||
if (pos !== undefined) {
|
||||
editor.commands.setNodeSelection(pos);
|
||||
}
|
||||
}, [editor, getPos]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(newWidth: number, newHeight: number) => {
|
||||
updateAttributes({ width: newWidth, height: newHeight });
|
||||
},
|
||||
[updateAttributes],
|
||||
);
|
||||
|
||||
const handleConvertToAttachment = useCallback(() => {
|
||||
if (!src) return;
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
const currentNode = editor.state.doc.nodeAt(pos);
|
||||
if (!currentNode || currentNode.type.name !== "pdf") return;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(
|
||||
{ from: pos, to: pos + currentNode.nodeSize },
|
||||
{
|
||||
type: "attachment",
|
||||
attrs: {
|
||||
url: currentNode.attrs.src,
|
||||
name: currentNode.attrs.name,
|
||||
attachmentId: currentNode.attrs.attachmentId,
|
||||
size: currentNode.attrs.size,
|
||||
mime: "application/pdf",
|
||||
},
|
||||
},
|
||||
)
|
||||
.run();
|
||||
}, [editor, src, getPos]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const pos = getPos();
|
||||
if (pos === undefined) return;
|
||||
editor.commands.setNodeSelection(pos);
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor, getPos]);
|
||||
|
||||
if (!src || !safeSrc) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={`${classes.pdfWrapper} ${placeholder ? classes.skeleton : ''}`} style={{ height: placeholder ? 600 : undefined }}>
|
||||
{placeholder && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
|
||||
<IconFileTypePdf size={32} stroke={1.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Failed to load PDF")}
|
||||
</Text>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle className={classes.pdfNodeView}>
|
||||
<div className={classes.pdfContainer}>
|
||||
<ResizableWrapper
|
||||
initialWidth={nodeWidth || 800}
|
||||
initialHeight={nodeHeight || 600}
|
||||
minWidth={200}
|
||||
maxWidth={1200}
|
||||
minHeight={200}
|
||||
maxHeight={1200}
|
||||
onResize={handleResize}
|
||||
isEditable={editor.isEditable}
|
||||
selected={selected}
|
||||
className={clsx(classes.pdfResizeWrapper, {
|
||||
"ProseMirror-selectednode": selected,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
className={classes.pdfIframe}
|
||||
src={safeSrc}
|
||||
loading="lazy"
|
||||
frameBorder="0"
|
||||
onError={() => setHasError(true)}
|
||||
onLoad={(e) => {
|
||||
try {
|
||||
const iframe = e.currentTarget;
|
||||
const status = iframe.contentDocument?.querySelector("pre")?.textContent;
|
||||
if (status && status.includes('"statusCode":404')) {
|
||||
setHasError(true);
|
||||
}
|
||||
} catch {
|
||||
// cross-origin - can't inspect, assume OK
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{editor.isEditable && (
|
||||
<div className={classes.hoverMenu}>
|
||||
<Tooltip position="top" label={t("Convert to attachment")} withinPortal>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="dark"
|
||||
onClick={handleConvertToAttachment}
|
||||
aria-label={t("Convert to attachment")}
|
||||
>
|
||||
<IconPaperclip size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="dark"
|
||||
onClick={handleDelete}
|
||||
aria-label={t("Delete")}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ResizableWrapper>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { handlePdfUpload } from "@docmost/editor-ext";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||
import { formatBytes } from "@/lib";
|
||||
import i18n from "@/i18n.ts";
|
||||
|
||||
export const uploadPdfAction = handlePdfUpload({
|
||||
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 !== "application/pdf") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > getFileUploadSizeLimit()) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||
limit: formatBytes(getFileUploadSizeLimit()),
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -87,7 +87,13 @@ const CommandList = ({
|
||||
|
||||
return flatItems.length > 0 ? (
|
||||
<Paper id="slash-command" shadow="md" p="xs" withBorder>
|
||||
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
|
||||
<ScrollArea
|
||||
viewportRef={viewportRef}
|
||||
h={350}
|
||||
w={270}
|
||||
scrollbarSize={8}
|
||||
overscrollBehavior="contain"
|
||||
>
|
||||
{Object.entries(items).map(([category, categoryItems]) => (
|
||||
<div key={category}>
|
||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||
@@ -103,10 +109,7 @@ const CommandList = ({
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
component="div"
|
||||
>
|
||||
<ActionIcon variant="default" component="div">
|
||||
<item.icon size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
IconMath,
|
||||
IconMathFunction,
|
||||
IconMovie,
|
||||
IconMusic,
|
||||
IconPaperclip,
|
||||
IconFileTypePdf,
|
||||
IconPhoto,
|
||||
IconTable,
|
||||
IconTypography,
|
||||
@@ -30,7 +32,9 @@ import {
|
||||
} from "@/features/editor/components/slash-menu/types";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload any image from your device.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
searchTerms: ["photo", "picture", "media", "file", "attachment"],
|
||||
icon: IconPhoto,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
{
|
||||
title: "Video",
|
||||
description: "Upload any video from your device.",
|
||||
searchTerms: ["video", "mp4", "media"],
|
||||
searchTerms: ["video", "mp4", "media", "file", "attachment"],
|
||||
icon: IconMovie,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Audio",
|
||||
description: "Upload any audio from your device.",
|
||||
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
|
||||
icon: IconMusic,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
// upload audio
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "audio/*";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadAudioAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Embed PDF",
|
||||
description: "Upload and embed a PDF file.",
|
||||
searchTerms: ["pdf", "document", "embed"],
|
||||
icon: IconFileTypePdf,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
if (!pageId) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "application/pdf";
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadPdfAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "File attachment",
|
||||
description: "Upload any file from your device.",
|
||||
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
||||
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
|
||||
icon: IconPaperclip,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
editor.chain().focus().deleteRange(range).setDrawio().run(),
|
||||
},
|
||||
{
|
||||
title: "Excalidraw diagram",
|
||||
title: "Excalidraw (Whiteboard)",
|
||||
description: "Draw and sketch excalidraw diagrams",
|
||||
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
|
||||
icon: IconExcalidraw,
|
||||
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
{
|
||||
title: "YouTube",
|
||||
description: "Embed YouTube video",
|
||||
searchTerms: ["youtube", "yt"],
|
||||
searchTerms: ["youtube", "yt", "media", "video"],
|
||||
icon: YoutubeIcon,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
@@ -650,7 +718,11 @@ export const getSuggestionItems = ({
|
||||
});
|
||||
|
||||
if (filteredItems.length) {
|
||||
filteredGroups[group] = filteredItems;
|
||||
filteredGroups[group] = filteredItems.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
|
||||
return aTitle - bTitle;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const renderItems = () => {
|
||||
getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.zIndex = "9999";
|
||||
popup.style.zIndex = "199";
|
||||
popup.style.position = "absolute";
|
||||
popup.style.top = "0";
|
||||
popup.style.left = "0";
|
||||
|
||||
@@ -34,7 +34,7 @@ export const TableMenu = React.memo(
|
||||
if (isTextSelected(editor)) return false;
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor]
|
||||
[editor],
|
||||
);
|
||||
|
||||
const getReferencedVirtualElement = useCallback(() => {
|
||||
@@ -121,7 +121,11 @@ export const TableMenu = React.memo(
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add left column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="subtle"
|
||||
@@ -132,7 +136,11 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Add right column")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add right column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={addColumnRight}
|
||||
variant="subtle"
|
||||
@@ -143,7 +151,11 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Delete column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="subtle"
|
||||
@@ -156,7 +168,11 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Add row above")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add row above")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={addRowAbove}
|
||||
variant="subtle"
|
||||
@@ -167,7 +183,11 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Add row below")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Add row below")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={addRowBelow}
|
||||
variant="subtle"
|
||||
@@ -178,7 +198,7 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<Tooltip position="top" label={t("Delete row")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="subtle"
|
||||
@@ -191,7 +211,11 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Toggle header row")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="subtle"
|
||||
@@ -202,7 +226,11 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Toggle header column")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="subtle"
|
||||
@@ -215,7 +243,11 @@ export const TableMenu = React.memo(
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Delete table")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
variant="subtle"
|
||||
@@ -228,7 +260,7 @@ export const TableMenu = React.memo(
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default TableMenu;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
@@ -26,6 +29,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.videoWrapper,
|
||||
!src && placeholder && classes.skeleton,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
@@ -59,7 +60,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && (
|
||||
{!src && !previewSrc && placeholder && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
@@ -69,6 +70,9 @@ export default function VideoView(props: NodeViewProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && !placeholder && (
|
||||
<video className={classes.video} controls />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
import { getNodeType } from "@tiptap/react";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { Transaction } from "@tiptap/pm/state";
|
||||
|
||||
// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4
|
||||
// Adapted from prosemirror-commands wrapDispatchForJoin
|
||||
function autoJoin(
|
||||
transactions: readonly Transaction[],
|
||||
newTr: Transaction,
|
||||
nodeTypes: NodeType[]
|
||||
) {
|
||||
// Collect changed ranges across all transactions, mapping earlier ranges
|
||||
// forward through later mappings so every position lands in newTr.doc space.
|
||||
let ranges: number[] = [];
|
||||
for (const tr of transactions) {
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
let map = tr.mapping.maps[i];
|
||||
if (!map) continue;
|
||||
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!);
|
||||
map.forEach((_s, _e, from, to) => ranges.push(from, to));
|
||||
}
|
||||
}
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
// Resolve against newTr.doc — the same document we will join on.
|
||||
let joinable: number[] = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
let from = ranges[i]!,
|
||||
to = ranges[i + 1]!;
|
||||
let $from = newTr.doc.resolve(from),
|
||||
depth = $from.sharedDepth(to),
|
||||
parent = $from.node(depth);
|
||||
for (
|
||||
let index = $from.indexAfter(depth), pos = $from.after(depth + 1);
|
||||
pos <= to;
|
||||
++index
|
||||
) {
|
||||
let after = parent.maybeChild(index);
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
let before = parent.child(index - 1);
|
||||
if (before.type == after.type && nodeTypes.includes(before.type))
|
||||
joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
// Join the joinable points (reverse order to preserve earlier positions)
|
||||
let joined = false;
|
||||
joinable.sort((a, b) => a - b);
|
||||
for (let i = joinable.length - 1; i >= 0; i--) {
|
||||
if (canJoin(newTr.doc, joinable[i]!)) {
|
||||
newTr.join(joinable[i]!);
|
||||
joined = true;
|
||||
}
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
export interface AutoJoinerOptions {
|
||||
elementsToJoin: string[];
|
||||
}
|
||||
|
||||
const AutoJoiner = Extension.create<AutoJoinerOptions>({
|
||||
name: "autoJoiner",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
elementsToJoin: [],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const plugin = new PluginKey(this.name);
|
||||
const joinableNodes = [
|
||||
this.editor.schema.nodes.bulletList,
|
||||
this.editor.schema.nodes.orderedList,
|
||||
];
|
||||
this.options.elementsToJoin.forEach((element) => {
|
||||
const nodeTyp = getNodeType(element, this.editor.schema);
|
||||
joinableNodes.push(nodeTyp);
|
||||
});
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: plugin,
|
||||
appendTransaction(transactions, _, newState) {
|
||||
let newTr = newState.tr;
|
||||
if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default AutoJoiner;
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
TiptapImage,
|
||||
Callout,
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
LinkExtension,
|
||||
Selection,
|
||||
Attachment,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
TiptapPdf,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
@@ -70,11 +72,13 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AudioView from "@/features/editor/components/audio/audio-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 EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
@@ -95,6 +99,7 @@ import i18n from "@/i18n.ts";
|
||||
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
|
||||
import EmojiCommand from "./emoji-command";
|
||||
import { countWords } from "alfaaz";
|
||||
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("mermaid", plaintext);
|
||||
@@ -139,6 +144,25 @@ export const mainExtensions = [
|
||||
}),
|
||||
];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) return false;
|
||||
if (!editor.isActive("code")) return false;
|
||||
|
||||
const $from = editor.state.doc.resolve(from);
|
||||
const codeType = editor.state.schema.marks.code;
|
||||
const nodeAfter = $from.nodeAfter;
|
||||
|
||||
if (nodeAfter && codeType.isInSet(nodeAfter.marks)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.chain().unsetCode().splitBlock().run();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
SharedStorage,
|
||||
Heading,
|
||||
@@ -250,8 +274,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createImageHandle,
|
||||
@@ -263,14 +287,17 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-video"),
|
||||
},
|
||||
}),
|
||||
TiptapAudio.configure({
|
||||
view: AudioView,
|
||||
}),
|
||||
Callout.configure({
|
||||
view: CalloutView,
|
||||
}),
|
||||
@@ -291,8 +318,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
@@ -304,8 +331,8 @@ export const mainExtensions = [
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
minWidth: 24,
|
||||
minHeight: 16,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
@@ -315,6 +342,9 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
TiptapPdf.configure({
|
||||
view: PdfView,
|
||||
}),
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
@@ -345,6 +375,9 @@ export const mainExtensions = [
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: [],
|
||||
}),
|
||||
] as any;
|
||||
|
||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { DOMParser } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml } from "@docmost/editor-ext";
|
||||
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
@@ -19,6 +19,27 @@ export const MarkdownClipboard = Extension.create({
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||
let topLevelCount = 0;
|
||||
let hasList = false;
|
||||
slice.content.forEach((node) => {
|
||||
if (listTypes.includes(node.type.name)) {
|
||||
hasList = true;
|
||||
topLevelCount += node.childCount;
|
||||
} else {
|
||||
topLevelCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasList || topLevelCount < 2) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||
const fragment = serializer.serializeFragment(slice.content);
|
||||
div.appendChild(fragment);
|
||||
return htmlToMarkdown(div.innerHTML);
|
||||
},
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
@@ -29,49 +50,80 @@ export const MarkdownClipboard = Extension.create({
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const html = event.clipboardData.getData("text/html");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
const language = vscodeData?.mode;
|
||||
|
||||
if (language !== "markdown") {
|
||||
const isVscodeMarkdown = language === "markdown";
|
||||
const isPlainTextOnly = !html && !vscode && !!text;
|
||||
|
||||
if (!isVscodeMarkdown && !isPlainTextOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPlainTextOnly) {
|
||||
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = find(text, {
|
||||
defaultProtocol: "http",
|
||||
}).find((item) => item.isLink && item.value === text);
|
||||
|
||||
if (link) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const { tr } = view.state;
|
||||
const { from, to } = view.state.selection;
|
||||
|
||||
const html = markdownToHtml(text);
|
||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(elementFromString(html), {
|
||||
).parseSlice(elementFromString(parsed), {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
tr.setMeta('paste', true)
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
clipboardTextParser: (text, context, plainText) => {
|
||||
const link = find(text, {
|
||||
defaultProtocol: "http",
|
||||
}).find((item) => item.isLink && item.value === text);
|
||||
// Strip trailing whitespace-only paragraphs from pasted content.
|
||||
// Terminals (GNOME Terminal, etc.) often include trailing
|
||||
// whitespace in their HTML clipboard data, which ProseMirror
|
||||
// parses as an extra paragraph. Inside a list item this creates
|
||||
// an orphan empty line that breaks the list structure.
|
||||
transformPasted: (slice) => {
|
||||
let { content, openStart, openEnd } = slice;
|
||||
|
||||
if (plainText || !this.options.transformPastedText || link) {
|
||||
// don't parse plaintext link to allow link paste handler to work
|
||||
// pasting with shift key prevents formatting
|
||||
return null;
|
||||
// Remove trailing paragraphs that contain only whitespace
|
||||
while (content.childCount > 1) {
|
||||
const lastChild = content.lastChild;
|
||||
if (
|
||||
lastChild?.type.name === "paragraph" &&
|
||||
lastChild.textContent.trim() === ""
|
||||
) {
|
||||
const children = [];
|
||||
for (let i = 0; i < content.childCount - 1; i++) {
|
||||
children.push(content.child(i));
|
||||
}
|
||||
content = Fragment.from(children);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = markdownToHtml(text);
|
||||
return DOMParser.fromSchema(this.editor.schema).parseSlice(
|
||||
elementFromString(parsed),
|
||||
{
|
||||
preserveWhitespace: true,
|
||||
context,
|
||||
},
|
||||
);
|
||||
if (content !== slice.content) {
|
||||
return new Slice(content, openStart, Math.max(openEnd, 1));
|
||||
}
|
||||
|
||||
return slice;
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export function FullEditor({
|
||||
@@ -25,6 +26,7 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
canComment,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
@@ -46,6 +48,7 @@ export function FullEditor({
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -37,14 +37,17 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
showReadOnlyCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
|
||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||
import {
|
||||
handleFileDrop,
|
||||
@@ -73,12 +76,14 @@ interface PageEditorProps {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
content: any;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export default function PageEditor({
|
||||
pageId,
|
||||
editable,
|
||||
content,
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
@@ -93,6 +98,7 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@@ -414,6 +420,7 @@ export default function PageEditor({
|
||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
@@ -421,7 +428,13 @@ export default function PageEditor({
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
|
||||
@@ -133,10 +133,18 @@
|
||||
border-top: 1px solid #68cef8;
|
||||
}
|
||||
|
||||
&[contenteditable="false"] hr.ProseMirror-selectednode {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #70cff8;
|
||||
}
|
||||
|
||||
&[contenteditable="false"] .ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& > .react-renderer {
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
margin-bottom: var(--mantine-spacing-sm);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.node-image, .node-video, .node-excalidraw, .node-drawio {
|
||||
.node-image, .node-video, .node-pdf, .node-excalidraw, .node-drawio {
|
||||
&.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
@@ -37,5 +37,28 @@
|
||||
font-size: var(--mantine-font-size-md);
|
||||
line-height: var(--mantine-line-height-md);
|
||||
}
|
||||
|
||||
.media-pulse {
|
||||
animation: media-pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
|
||||
background-size: 400% 400%;
|
||||
}
|
||||
|
||||
@keyframes media-pulse {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
100% {
|
||||
background-position: -135% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user