diff --git a/app/components/editor/TiptapArticleEditor.tsx b/app/components/editor/TiptapArticleEditor.tsx index db86049..b5a1c3f 100644 --- a/app/components/editor/TiptapArticleEditor.tsx +++ b/app/components/editor/TiptapArticleEditor.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, ReactNodeViewRenderer, useEditor, type Editor } from '@tiptap/react' import { Placeholder } from '@tiptap/extensions' import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' @@ -11,6 +11,8 @@ import { MathInline } from '~/lib/tiptap/math-inline' import { MarkdownClipboard } from '~/lib/tiptap/markdown-clipboard' import { editorHtmlToMarkdown, markdownToEditorHtml } from '~/lib/tiptap/markdown/serializer' import { markerForKind, type DiagramKind } from './diagrams/diagram-source' +import { useDiagramModals } from './diagrams/useDiagramModals' +import Spinner from '~/components/Spinner' import CodeBlockView from './nodeviews/CodeBlockView' import ImageView from './nodeviews/ImageView' import MathBlockView from './nodeviews/MathBlockView' @@ -21,9 +23,6 @@ import TiptapBubbleMenu from './TiptapBubbleMenu' import TiptapToolbar from './TiptapToolbar' import './tiptap-editor.css' -const ExcalidrawModal = lazy(() => import('./diagrams/ExcalidrawModal')) -const DrawioModal = lazy(() => import('./diagrams/DrawioModal')) - export interface TiptapArticleEditorApi { getMarkdown(): string } @@ -55,6 +54,7 @@ export default function TiptapArticleEditor({ // Modal for creating a new diagram, reachable from both the toolbar and // the slash command menu. Editing existing diagrams lives in ImageView. const [newDiagram, setNewDiagram] = useState(null) + const diagramModals = useDiagramModals() const extensions = useMemo( () => [ @@ -160,14 +160,17 @@ export default function TiptapArticleEditor({ {showToolbar && ( )} - {newDiagram && ( - - {newDiagram === 'excalidraw' ? ( - setNewDiagram(null)} /> - ) : ( - setNewDiagram(null)} /> - )} - + {newDiagram && !diagramModals && ( +
+ +
+ )} + {newDiagram && diagramModals && ( + newDiagram === 'excalidraw' ? ( + setNewDiagram(null)} /> + ) : ( + setNewDiagram(null)} /> + ) )} {imageError && (
diff --git a/app/components/editor/diagrams/useDiagramModals.ts b/app/components/editor/diagrams/useDiagramModals.ts new file mode 100644 index 0000000..21a27fb --- /dev/null +++ b/app/components/editor/diagrams/useDiagramModals.ts @@ -0,0 +1,46 @@ +import { useEffect, useState, type ComponentType } from 'react' + +export interface DiagramModalProps { + initialSrc: string | null + onCommit: (blob: Blob) => string | null + onClose: () => void +} + +export type DiagramModals = { + ExcalidrawModal: ComponentType + DrawioModal: ComponentType +} + +let cached: DiagramModals | null = null +let pending: Promise | null = null + +function loadModals(): Promise { + pending ??= Promise.all([import('./ExcalidrawModal'), import('./DrawioModal')]).then( + ([excalidraw, drawio]) => { + cached = { ExcalidrawModal: excalidraw.default, DrawioModal: drawio.default } + return cached + }, + ) + return pending +} + +// The diagram modals can't be delivered through lazy()/Suspense: while Tiptap +// node views are mounted, their flushSync/useSyncExternalStore updates starve +// React's interruptible passes (see ArticleEditor's workaround note), and the +// Suspense retry that would show the loaded modal never commits — the modal +// simply never appears. Plain sync state updates DO commit during the +// starvation, so the chunks are fetched at mount and delivered through state. +export function useDiagramModals(): DiagramModals | null { + const [modals, setModals] = useState(cached) + useEffect(() => { + if (modals) return + let cancelled = false + loadModals().then((m) => { + if (!cancelled) setModals(m) + }) + return () => { + cancelled = true + } + }, [modals]) + return modals +} diff --git a/app/components/editor/nodeviews/ImageView.tsx b/app/components/editor/nodeviews/ImageView.tsx index 10bec1e..b8e5ef2 100644 --- a/app/components/editor/nodeviews/ImageView.tsx +++ b/app/components/editor/nodeviews/ImageView.tsx @@ -1,10 +1,9 @@ -import { lazy, Suspense, useState } from 'react' +import { useState } from 'react' import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react' import { MAX_IMAGE_BYTES, MAX_IMAGE_LABEL } from '~/lib/security-constants' import { diagramKindFromTitle, type DiagramKind } from '../diagrams/diagram-source' - -const ExcalidrawModal = lazy(() => import('../diagrams/ExcalidrawModal')) -const DrawioModal = lazy(() => import('../diagrams/DrawioModal')) +import { useDiagramModals } from '../diagrams/useDiagramModals' +import Spinner from '~/components/Spinner' // Renders a content image. Images whose markdown title carries a `lemma:` // diagram marker double-click into the matching diagram editor; everything @@ -14,6 +13,7 @@ export default function ImageView({ node, updateAttributes, editor, selected }: const kind = diagramKindFromTitle(title) const [openEditor, setOpenEditor] = useState(null) const [error, setError] = useState(null) + const diagramModals = useDiagramModals() const selectedClass = selected ? 'outline outline-2 outline-blue-600 outline-offset-2' : '' @@ -55,14 +55,17 @@ export default function ImageView({ node, updateAttributes, editor, selected }: )} {error && {error}} - {openEditor && ( - - {openEditor === 'excalidraw' ? ( - setOpenEditor(null)} /> - ) : ( - setOpenEditor(null)} /> - )} - + {openEditor && !diagramModals && ( + + + + )} + {openEditor && diagramModals && ( + openEditor === 'excalidraw' ? ( + setOpenEditor(null)} /> + ) : ( + setOpenEditor(null)} /> + ) )} )