mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 15:34:05 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ada5a1b4 | |||
| 0ae407839f | |||
| d524073d86 | |||
| 5c5fff517c | |||
| 3115c5e097 |
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -46,7 +47,7 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "audio";
|
const predicate = (node: PMNode) => node.type.name === "audio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
import { CalloutType, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
@@ -55,7 +55,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "callout";
|
const predicate = (node: PMNode) => node.type.name === "callout";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconCopy,
|
IconCopy,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { isTextSelected } from "@docmost/editor-ext";
|
import { isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||||
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
@@ -82,7 +82,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state) return false;
|
if (!state || !isEditorReady(editor)) return false;
|
||||||
if (!editor.isActive("columns")) return false;
|
if (!editor.isActive("columns")) return false;
|
||||||
if (isTextSelected(editor)) return false;
|
if (isTextSelected(editor)) return false;
|
||||||
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
||||||
@@ -121,7 +121,7 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "columns";
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -81,7 +82,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "drawio";
|
const predicate = (node: PMNode) => node.type.name === "drawio";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
|
||||||
|
const ExcalidrawMenu = lazy(
|
||||||
|
() => import("@/features/editor/components/excalidraw/excalidraw-menu.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ExcalidrawMenuLazy(props: EditorMenuProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ExcalidrawMenu {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -94,7 +95,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
const predicate = (node: PMNode) => node.type.name === "excalidraw";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
const ExcalidrawView = lazy(
|
||||||
|
() => import("@/features/editor/components/excalidraw/excalidraw-view.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ExcalidrawViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ExcalidrawView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -56,7 +57,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "image";
|
const predicate = (node: PMNode) => node.type.name === "image";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -37,9 +38,8 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
const shouldShow = useCallback(
|
const shouldShow = useCallback(
|
||||||
({ state }: ShouldShowProps) => {
|
({ state }: ShouldShowProps) => {
|
||||||
if (!state || !editor.isActive("pdf")) {
|
if (!state || !isEditorReady(editor)) return false;
|
||||||
return false;
|
if (!editor.isActive("pdf")) return false;
|
||||||
}
|
|
||||||
|
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
|
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
|
||||||
@@ -51,7 +51,7 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "pdf";
|
const predicate = (node: PMNode) => node.type.name === "pdf";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ActionIcon, Tooltip } from "@mantine/core";
|
|||||||
import { IconTrash } from "@tabler/icons-react";
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
|
|
||||||
interface SubpagesMenuProps {
|
interface SubpagesMenuProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -33,6 +34,7 @@ export const SubpagesMenu = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferenceClientRect = useCallback(() => {
|
const getReferenceClientRect = useCallback(() => {
|
||||||
|
if (!isEditorReady(editor)) return new DOMRect();
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "subpages";
|
const predicate = (node: PMNode) => node.type.name === "subpages";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export const ColumnHandle = React.memo(function ColumnHandle({
|
|||||||
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
// (the plugin re-emits `hoveringCell` with the mapped pos a tick later);
|
||||||
// unmounting the source element here would make pragmatic-dnd silently
|
// unmounting the source element here would make pragmatic-dnd silently
|
||||||
// abort the active drag.
|
// abort the active drag.
|
||||||
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
|
||||||
|
// an external drop reflows the doc before the plugin re-emits
|
||||||
|
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
||||||
|
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
||||||
|
const lookupDom = editor.view.nodeDOM(anchorPos);
|
||||||
|
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ export const RowHandle = React.memo(function RowHandle({
|
|||||||
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
// See ColumnHandle for the rationale: keep the last valid cell DOM cached
|
||||||
// so the handle div stays mounted across stale-anchor renders, otherwise
|
// so the handle div stays mounted across stale-anchor renders, otherwise
|
||||||
// pragmatic-dnd silently aborts an in-flight drag.
|
// pragmatic-dnd silently aborts an in-flight drag.
|
||||||
const lookupCellDom = editor.view.nodeDOM(anchorPos) as HTMLElement | null;
|
// `nodeDOM` is typed as `Node | null` — when `anchorPos` goes stale (e.g.
|
||||||
|
// an external drop reflows the doc before the plugin re-emits
|
||||||
|
// hoveringCell), it can resolve to a Text node, on which `.closest` is
|
||||||
|
// undefined. Filter to HTMLElement so downstream consumers stay safe.
|
||||||
|
const lookupDom = editor.view.nodeDOM(anchorPos);
|
||||||
|
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
|
||||||
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
|
||||||
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../common/toolbar-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ export const TableMenu = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "table";
|
const predicate = (node: PMNode) => node.type.name === "table";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
|||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { isEditorReady } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
@@ -53,7 +54,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!isEditorReady(editor)) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
const predicate = (node: PMNode) => node.type.name === "video";
|
const predicate = (node: PMNode) => node.type.name === "video";
|
||||||
const parent = findParentNode(predicate)(selection);
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
|||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-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 CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
codeSplitting: {
|
advancedChunks: {
|
||||||
groups: [
|
groups: [
|
||||||
{ name: "vendor-mantine", test: /@mantine/ },
|
{
|
||||||
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ },
|
name: "vendor-mantine",
|
||||||
{ name: "vendor-excalidraw", test: /excalidraw/ },
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
{ name: "vendor-katex", test: /katex/ },
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -162,6 +162,28 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty callout: delete the whole node so Backspace inside it isn't
|
||||||
|
// a no-op (isolating: true blocks the default join with the block
|
||||||
|
// above).
|
||||||
|
const calloutDepth = $from.depth - 1;
|
||||||
|
if (calloutDepth >= 0) {
|
||||||
|
const calloutNode = $from.node(calloutDepth);
|
||||||
|
if (
|
||||||
|
calloutNode.type === this.type &&
|
||||||
|
calloutNode.childCount === 1 &&
|
||||||
|
calloutNode.firstChild?.content.size === 0
|
||||||
|
) {
|
||||||
|
const calloutPos = $from.before(calloutDepth);
|
||||||
|
const { tr } = state;
|
||||||
|
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(tr.doc.resolve(calloutPos), -1),
|
||||||
|
);
|
||||||
|
view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const previousPosition = $from.before($from.depth) - 1;
|
const previousPosition = $from.before($from.depth) - 1;
|
||||||
|
|
||||||
// If nothing above to join with
|
// If nothing above to join with
|
||||||
@@ -207,6 +229,56 @@ export const Callout = Node.create<CalloutOptions>({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Exit the callout into a fresh paragraph below when the cursor sits
|
||||||
|
// in an empty trailing child. An empty callout (single empty
|
||||||
|
// paragraph) exits on the first Enter and keeps the empty callout
|
||||||
|
// intact; a callout with content needs the double-Enter pattern
|
||||||
|
// (first Enter splits, second Enter on the new trailing empty exits
|
||||||
|
// and removes that trailing paragraph).
|
||||||
|
Enter: ({ editor }) => {
|
||||||
|
const { state, view } = editor;
|
||||||
|
const { selection } = state;
|
||||||
|
if (!selection.empty) return false;
|
||||||
|
|
||||||
|
const { $from } = selection;
|
||||||
|
const calloutDepth = $from.depth - 1;
|
||||||
|
if (calloutDepth < 0) return false;
|
||||||
|
|
||||||
|
const calloutNode = $from.node(calloutDepth);
|
||||||
|
if (calloutNode.type !== this.type) return false;
|
||||||
|
if ($from.parent.content.size !== 0) return false;
|
||||||
|
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphType = state.schema.nodes.paragraph;
|
||||||
|
const containerDepth = calloutDepth - 1;
|
||||||
|
const container = $from.node(containerDepth);
|
||||||
|
const indexAfter = $from.indexAfter(containerDepth);
|
||||||
|
if (
|
||||||
|
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calloutEnd = $from.after(calloutDepth);
|
||||||
|
const paragraph = paragraphType.create();
|
||||||
|
const { tr } = state;
|
||||||
|
|
||||||
|
if (calloutNode.childCount === 1) {
|
||||||
|
tr.insert(calloutEnd, paragraph);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
|
||||||
|
} else {
|
||||||
|
tr.delete($from.before(), $from.after());
|
||||||
|
const insertPos = tr.mapping.map(calloutEnd);
|
||||||
|
tr.insert(insertPos, paragraph);
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch(tr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
|
||||||
import CodeBlock from '@tiptap/extension-code-block';
|
import CodeBlock from '@tiptap/extension-code-block';
|
||||||
|
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { GapCursor } from '@tiptap/pm/gapcursor';
|
||||||
|
|
||||||
import { LowlightPlugin } from './lowlight-plugin.js';
|
import { LowlightPlugin } from './lowlight-plugin.js';
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
@@ -19,7 +21,11 @@ const TAB_CHAR = '\u00A0\u00A0';
|
|||||||
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
* @see https://tiptap.dev/api/nodes/code-block-lowlight
|
||||||
*/
|
*/
|
||||||
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
||||||
|
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
|
||||||
|
// can intercept before gapcursor takes over.
|
||||||
|
priority: 101,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
@@ -35,8 +41,86 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
|
const isMermaid = (node: any) =>
|
||||||
|
node?.type === this.type && node.attrs.language === 'mermaid';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
|
// Stop at the gap (or enter mermaid source) instead of jumping
|
||||||
|
// straight into the next block, so the user can place a cursor
|
||||||
|
// between two adjacent isolating blocks.
|
||||||
|
ArrowDown: ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection, doc } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) return false;
|
||||||
|
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||||
|
|
||||||
|
const after = $from.after();
|
||||||
|
if (after >= doc.content.size) {
|
||||||
|
return editor.commands.exitCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const $after = doc.resolve(after);
|
||||||
|
const nodeAfter = $after.nodeAfter;
|
||||||
|
|
||||||
|
if (isMermaid(nodeAfter)) {
|
||||||
|
return editor.commands.command(({ tr }) => {
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nodeAfter?.type.spec.isolating &&
|
||||||
|
!nodeAfter.type.spec.atom
|
||||||
|
) {
|
||||||
|
return editor.commands.command(({ tr }) => {
|
||||||
|
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.commands.command(({ tr }) => {
|
||||||
|
tr.setSelection(Selection.near(tr.doc.resolve(after)));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Mirror of ArrowDown; upstream has no ArrowUp handler.
|
||||||
|
ArrowUp: ({ editor }) => {
|
||||||
|
const { state } = editor;
|
||||||
|
const { selection, doc } = state;
|
||||||
|
const { $from, empty } = selection;
|
||||||
|
|
||||||
|
if (!empty || $from.parent.type !== this.type) return false;
|
||||||
|
if ($from.parentOffset !== 0) return false;
|
||||||
|
|
||||||
|
const before = $from.before();
|
||||||
|
if (before <= 0) return false;
|
||||||
|
|
||||||
|
const $before = doc.resolve(before);
|
||||||
|
const nodeBefore = $before.nodeBefore;
|
||||||
|
|
||||||
|
if (isMermaid(nodeBefore)) {
|
||||||
|
return editor.commands.command(({ tr }) => {
|
||||||
|
tr.setSelection(TextSelection.create(tr.doc, before - 1));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nodeBefore?.type.spec.isolating &&
|
||||||
|
!nodeBefore.type.spec.atom
|
||||||
|
) {
|
||||||
|
return editor.commands.command(({ tr }) => {
|
||||||
|
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
'Mod-a': () => {
|
'Mod-a': () => {
|
||||||
if (this.editor.isActive('codeBlock')) {
|
if (this.editor.isActive('codeBlock')) {
|
||||||
const { state } = this.editor;
|
const { state } = this.editor;
|
||||||
@@ -84,6 +168,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
},
|
},
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
|
const codeBlockType = this.type;
|
||||||
return [
|
return [
|
||||||
...(this.parent?.() || []),
|
...(this.parent?.() || []),
|
||||||
LowlightPlugin({
|
LowlightPlugin({
|
||||||
@@ -91,6 +176,60 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
|
|||||||
lowlight: this.options.lowlight,
|
lowlight: this.options.lowlight,
|
||||||
defaultLanguage: this.options.defaultLanguage,
|
defaultLanguage: this.options.defaultLanguage,
|
||||||
}),
|
}),
|
||||||
|
// Mermaid hides its <pre> when unselected, so the browser's native
|
||||||
|
// vertical caret movement skips past it. Land the cursor inside the
|
||||||
|
// source explicitly.
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleKeyDown: (view, event) => {
|
||||||
|
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { state } = view;
|
||||||
|
const { selection } = state;
|
||||||
|
if (
|
||||||
|
!selection.empty ||
|
||||||
|
!(selection instanceof TextSelection)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { $from } = selection;
|
||||||
|
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
|
||||||
|
if (!view.endOfTextblock(dir)) return false;
|
||||||
|
|
||||||
|
const isMermaid = (node: any) =>
|
||||||
|
node?.type === codeBlockType && node.attrs.language === 'mermaid';
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
if ($from.parentOffset !== 0) return false;
|
||||||
|
const beforePos = $from.before();
|
||||||
|
const prev = state.doc.resolve(beforePos).nodeBefore;
|
||||||
|
if (!isMermaid(prev)) return false;
|
||||||
|
const endPos = beforePos - 1;
|
||||||
|
view.dispatch(
|
||||||
|
state.tr.setSelection(
|
||||||
|
TextSelection.create(state.doc, endPos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
|
||||||
|
const afterPos = $from.after();
|
||||||
|
const next = state.doc.resolve(afterPos).nodeAfter;
|
||||||
|
if (!isMermaid(next)) return false;
|
||||||
|
const startPos = afterPos + 1;
|
||||||
|
view.dispatch(
|
||||||
|
state.tr.setSelection(
|
||||||
|
TextSelection.create(state.doc, startPos),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -338,6 +338,15 @@ export const isRowGripSelected = ({
|
|||||||
return !!gripRow;
|
return !!gripRow;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TipTap's `editor.view` proxy throws if accessed before mount or after destroy.
|
||||||
|
// Guard floating-menu callbacks (getReferencedVirtualElement, shouldShow) with
|
||||||
|
// this before touching `editor.view.nodeDOM(...)`.
|
||||||
|
export function isEditorReady(
|
||||||
|
editor: Editor | null | undefined,
|
||||||
|
): editor is Editor {
|
||||||
|
return !!editor && editor.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
export function isTextSelected(editor: Editor) {
|
export function isTextSelected(editor: Editor) {
|
||||||
const {
|
const {
|
||||||
state: {
|
state: {
|
||||||
|
|||||||
Reference in New Issue
Block a user