diff --git a/app/components/editor/ArticleEditor.tsx b/app/components/editor/ArticleEditor.tsx index 63040d2..ecd4b55 100644 --- a/app/components/editor/ArticleEditor.tsx +++ b/app/components/editor/ArticleEditor.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useEffect, useRef, useState } from 'react' +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import CodeMirrorMarkdownEditor from '~/components/CodeMirrorMarkdownEditor' import Spinner from '~/components/Spinner' import type { LossyReport } from '~/lib/tiptap/markdown/lossy-detect' @@ -14,6 +15,9 @@ type EditorMode = 'markdown' | 'visual' export interface ArticleEditorApi { getMarkdown(): string + // Unmounts the visual editor synchronously. Call right before a + // programmatic navigation (see the workaround note in ArticleEditor). + detach(): void } interface ArticleEditorProps { @@ -42,6 +46,49 @@ export default function ArticleEditor({ initialValue = '', onChange, apiRef }: A const [lossyWarning, setLossyWarning] = useState(null) const currentMarkdownRef = useRef(initialValue) const tiptapApiRef = useRef(null) + // Workaround for a Tiptap/React 19 incompatibility: React node views flush + // portal updates synchronously (flushSync + useSyncExternalStore in + // @tiptap/react, still present in 3.30). Sync external-store updates cannot + // participate in React transitions, so while an editor with any mounted + // node view exists, every React Router navigation transition is restarted + // indefinitely — the URL changes but the old page stays frozen. (A + // useBlocker can't help: its own state change rides the same starved + // transition.) Plain sync updates DO commit during the starvation, so the + // fix is to unmount the editor with flushSync before the router transition + // starts: on in-app link clicks (capture phase), on popstate, and via + // detach() before programmatic navigations. + const [leaving, setLeaving] = useState(false) + const leavingRef = useRef(false) + + const leave = useCallback(() => { + if (leavingRef.current) return + leavingRef.current = true + flushSync(() => setLeaving(true)) + }, []) + + useEffect(() => { + if (mode !== 'visual') return + const onClickCapture = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0) return + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + const anchor = (event.target as Element | null)?.closest?.('a[href]') + if (!anchor) return + // Only in-app navigations; new-tab/download links don't unmount anything. + if (anchor.getAttribute('target') || anchor.hasAttribute('download')) return + const href = anchor.getAttribute('href') ?? '' + if (!href.startsWith('/')) return + // Links inside the document being edited never navigate (openOnClick: false). + if (anchor.closest('.tiptap-article-editor')) return + leave() + } + const onPopState = () => leave() + document.addEventListener('click', onClickCapture, true) + window.addEventListener('popstate', onPopState) + return () => { + document.removeEventListener('click', onClickCapture, true) + window.removeEventListener('popstate', onPopState) + } + }, [mode, leave]) const getMarkdown = () => mode === 'visual' && tiptapApiRef.current @@ -53,11 +100,14 @@ export default function ArticleEditor({ initialValue = '', onChange, apiRef }: A useEffect(() => { if (!apiRef) return - apiRef.current = { getMarkdown: () => getMarkdownRef.current() } + apiRef.current = { + getMarkdown: () => getMarkdownRef.current(), + detach: () => leave(), + } return () => { apiRef.current = null } - }, [apiRef]) + }, [apiRef, leave]) // An article opened directly in visual mode (persisted preference) gets the // same lossy-content check a mid-session switch does. @@ -160,20 +210,26 @@ export default function ArticleEditor({ initialValue = '', onChange, apiRef }: A )} {mode === 'visual' ? ( - - - - } - > - - + leaving ? ( +
+ +
+ ) : ( + + + + } + > + + + ) ) : ( import('./diagrams/ExcalidrawModal')) +const DrawioModal = lazy(() => import('./diagrams/DrawioModal')) + export interface TiptapArticleEditorApi { getMarkdown(): string } @@ -44,6 +52,9 @@ export default function TiptapArticleEditor({ const debounceRef = useRef | null>(null) const [imageError, setImageError] = useState(null) const imageErrorTimerRef = useRef | null>(null) + // 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 extensions = useMemo( () => [ @@ -69,8 +80,10 @@ export default function TiptapArticleEditor({ }, }).configure({ inline: true }), }), - Placeholder.configure({ placeholder: 'Write your article…' }), + Placeholder.configure({ placeholder: 'Write your article… Type "/" for blocks' }), MarkdownClipboard, + // setNewDiagram is a stable useState setter, safe in this memo. + createSlashCommand(() => buildSlashItems(setNewDiagram)), ], [], ) @@ -124,9 +137,38 @@ export default function TiptapArticleEditor({ imageErrorTimerRef.current = setTimeout(() => setImageError(null), 4000) } + const commitNewDiagram = (blob: Blob): string | null => { + if (!editor || !newDiagram) return 'Editor is not ready.' + if (blob.size > MAX_IMAGE_BYTES) { + return `Diagram exceeds ${MAX_IMAGE_LABEL}. Simplify it or split it up.` + } + editor + .chain() + .focus() + .setImage({ + src: URL.createObjectURL(blob), + alt: newDiagram === 'excalidraw' ? 'Excalidraw diagram' : 'Draw.io diagram', + title: markerForKind(newDiagram), + }) + .run() + setNewDiagram(null) + return null + } + return (
- {showToolbar && } + {showToolbar && ( + + )} + {newDiagram && ( + + {newDiagram === 'excalidraw' ? ( + setNewDiagram(null)} /> + ) : ( + setNewDiagram(null)} /> + )} + + )} {imageError && (

{imageError}

@@ -143,6 +185,23 @@ export default function TiptapArticleEditor({
)} {editor && } + {editor && ( + +
+ +
+
+ )} import('./diagrams/ExcalidrawModal')) -const DrawioModal = lazy(() => import('./diagrams/DrawioModal')) +import type { DiagramKind } from './diagrams/diagram-source' +import { FENCE_TEMPLATES, insertFence } from './insert-helpers' interface ToolbarProps { editor: Editor | null onImageError?: (message: string) => void + // Diagram modals are hosted by TiptapArticleEditor so the slash command + // menu can open them too. + onNewDiagram?: (kind: DiagramKind) => void } interface ToolbarAction { @@ -48,24 +49,8 @@ interface ToolbarAction { shortcut?: string } -// Inserts a fenced code block with the given language and starter content — -// the visual-editor counterpart of the markdown toolbar's fence templates. -function insertFence(editor: Editor, language: string, template: string) { - editor - .chain() - .focus() - .insertContent({ - type: 'codeBlock', - attrs: { language }, - content: [{ type: 'text', text: template }], - }) - .run() -} - -const TiptapToolbar: React.FC = ({ editor, onImageError }) => { +const TiptapToolbar: React.FC = ({ editor, onImageError, onNewDiagram }) => { const imageInputRef = useRef(null) - // Modal for creating a new diagram; editing existing ones lives in ImageView. - const [newDiagram, setNewDiagram] = useState(null) // Re-render on every transaction so active states track the selection. const [, bump] = useReducer((x: number) => x + 1, 0) @@ -122,17 +107,17 @@ const TiptapToolbar: React.FC = ({ editor, onImageError }) => { { id: 'link', label: 'Link', icon: LinkIcon, action: editLink, isActive: () => editor.isActive('link') }, { id: 'image', label: 'Insert image', icon: PhotoIcon, action: () => imageInputRef.current?.click() }, { id: 'separator1', label: '', icon: MinusIcon, action: () => {}, separator: true }, - { id: 'embed', label: 'Embed from Bluesky, YouTube, Apple Music, etc.', icon: GlobeAltIcon, action: () => insertFence(editor, 'embed', 'https://bsky.app/profile/bsky.app/post/3m2kdgmbbzc2o') }, + { id: 'embed', label: 'Embed from Bluesky, YouTube, Apple Music, etc.', icon: GlobeAltIcon, action: () => insertFence(editor, FENCE_TEMPLATES.embed.language, FENCE_TEMPLATES.embed.template) }, { id: 'spoiler', label: 'Spoiler', icon: EyeSlashIcon, action: () => editor.chain().focus().insertContent('::spoiler:: hidden text ::spoiler::').run() }, { id: 'code', label: 'Inline Code', icon: CodeBracketIcon, shortcut: 'Cmd+E', action: () => editor.chain().focus().toggleCode().run(), isActive: () => editor.isActive('code') }, - { id: 'code-block', label: 'Code Block', icon: CodeBracketSquareIcon, action: () => insertFence(editor, 'javascript', '// Your code here'), isActive: () => editor.isActive('codeBlock') }, + { id: 'code-block', label: 'Code Block', icon: CodeBracketSquareIcon, action: () => insertFence(editor, FENCE_TEMPLATES.code.language, FENCE_TEMPLATES.code.template), isActive: () => editor.isActive('codeBlock') }, { id: 'math', label: 'Math Expression', icon: VariableIcon, action: () => editor.chain().focus().insertMathInline({ text: 'x^2' }).run() }, - { id: 'functionplot', label: 'Function Plot', icon: PresentationChartLineIcon, action: () => insertFence(editor, 'functionplot', 'x^2\nsin(x)\ncos(x)') }, - { id: 'mermaid', label: 'Mermaid Diagram', icon: ChartPieIcon, action: () => insertFence(editor, 'mermaid', 'flowchart TD\n A[Start] --> B{Decision}\n B -->|Yes| C[Action]\n B -->|No| D[End]\n C --> D') }, - { id: 'excalidraw', label: 'Excalidraw Drawing', icon: PaintBrushIcon, action: () => setNewDiagram('excalidraw') }, - { id: 'drawio', label: 'Draw.io Diagram', icon: RectangleGroupIcon, action: () => setNewDiagram('drawio') }, - { id: 'abc', label: 'ABC Musical Notation', icon: MusicalNoteIcon, action: () => insertFence(editor, 'abc', 'X:1\nT:Simple Tune\nK:C\nCDE F|GAB c|') }, - { id: 'fountain', label: 'Fountain Screenplay', icon: FilmIcon, action: () => insertFence(editor, 'fountain', 'INT. ROOM - DAY\n\nJOHN sits at a desk, writing.\n\nJOHN\n(thoughtful)\nThis is going to be great.\n\nFADE OUT.') }, + { id: 'functionplot', label: 'Function Plot', icon: PresentationChartLineIcon, action: () => insertFence(editor, FENCE_TEMPLATES.functionplot.language, FENCE_TEMPLATES.functionplot.template) }, + { id: 'mermaid', label: 'Mermaid Diagram', icon: ChartPieIcon, action: () => insertFence(editor, FENCE_TEMPLATES.mermaid.language, FENCE_TEMPLATES.mermaid.template) }, + { id: 'excalidraw', label: 'Excalidraw Drawing', icon: PaintBrushIcon, action: () => onNewDiagram?.('excalidraw') }, + { id: 'drawio', label: 'Draw.io Diagram', icon: RectangleGroupIcon, action: () => onNewDiagram?.('drawio') }, + { id: 'abc', label: 'ABC Musical Notation', icon: MusicalNoteIcon, action: () => insertFence(editor, FENCE_TEMPLATES.abc.language, FENCE_TEMPLATES.abc.template) }, + { id: 'fountain', label: 'Fountain Screenplay', icon: FilmIcon, action: () => insertFence(editor, FENCE_TEMPLATES.fountain.language, FENCE_TEMPLATES.fountain.template) }, ] return ( @@ -160,33 +145,6 @@ const TiptapToolbar: React.FC = ({ editor, onImageError }) => { } }} /> - {newDiagram && ( - - {(() => { - const commit = (blob: Blob): string | null => { - if (blob.size > MAX_IMAGE_BYTES) { - return `Diagram exceeds ${MAX_IMAGE_LABEL}. Simplify it or split it up.` - } - editor - .chain() - .focus() - .setImage({ - src: URL.createObjectURL(blob), - alt: newDiagram === 'excalidraw' ? 'Excalidraw diagram' : 'Draw.io diagram', - title: markerForKind(newDiagram), - }) - .run() - setNewDiagram(null) - return null - } - return newDiagram === 'excalidraw' ? ( - setNewDiagram(null)} /> - ) : ( - setNewDiagram(null)} /> - ) - })()} - - )} {actions.map((action) => { if (action.separator) { return ( diff --git a/app/components/editor/insert-helpers.ts b/app/components/editor/insert-helpers.ts new file mode 100644 index 0000000..e71066b --- /dev/null +++ b/app/components/editor/insert-helpers.ts @@ -0,0 +1,33 @@ +import type { Editor } from '@tiptap/react' + +// Inserts a fenced code block with the given language and starter content — +// the visual-editor counterpart of the markdown toolbar's fence templates. +// Shared by the fixed toolbar and the slash command menu. +export function insertFence(editor: Editor, language: string, template: string) { + editor + .chain() + .focus() + .insertContent({ + type: 'codeBlock', + attrs: { language }, + content: [{ type: 'text', text: template }], + }) + .run() +} + +export const FENCE_TEMPLATES = { + code: { language: 'javascript', template: '// Your code here' }, + mermaid: { + language: 'mermaid', + template: + 'flowchart TD\n A[Start] --> B{Decision}\n B -->|Yes| C[Action]\n B -->|No| D[End]\n C --> D', + }, + functionplot: { language: 'functionplot', template: 'x^2\nsin(x)\ncos(x)' }, + abc: { language: 'abc', template: 'X:1\nT:Simple Tune\nK:C\nCDE F|GAB c|' }, + fountain: { + language: 'fountain', + template: + 'INT. ROOM - DAY\n\nJOHN sits at a desk, writing.\n\nJOHN\n(thoughtful)\nThis is going to be great.\n\nFADE OUT.', + }, + embed: { language: 'embed', template: 'https://bsky.app/profile/bsky.app/post/3m2kdgmbbzc2o' }, +} as const diff --git a/app/components/editor/navigation-freeze.test.tsx b/app/components/editor/navigation-freeze.test.tsx new file mode 100644 index 0000000..ae8d76b --- /dev/null +++ b/app/components/editor/navigation-freeze.test.tsx @@ -0,0 +1,196 @@ +// Temporary diagnostic: reproduces the frozen-navigation bug (URL changes but +// the old view stays) by doing what React Router does — unmounting the editor +// inside startTransition — and bisecting which editor piece blocks the commit. +import { lazy, startTransition, Suspense, useState } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useNavigate } from 'react-router' +import { describe, expect, it } from 'vitest' +import { EditorContent, ReactNodeViewRenderer, useEditor, type Editor } from '@tiptap/react' +import { DragHandle } from '@tiptap/extension-drag-handle-react' +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' +import { createLemmaExtensions, lowlight } from '~/lib/tiptap/extensions' +import { MarkdownClipboard } from '~/lib/tiptap/markdown-clipboard' +import CodeBlockView from './nodeviews/CodeBlockView' +import { createSlashCommand } from './slash/slash-command' +import { buildSlashItems } from './slash/slash-items' +import TiptapBubbleMenu from './TiptapBubbleMenu' +import TiptapArticleEditor from './TiptapArticleEditor' + +// Content shaped like the article that reproduces the freeze: multiple image +// node views (diagrams), math, code fences with previews. +const RICH_MARKDOWN = [ + '# My title 2', + '', + '![Excalidraw diagram](https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:x/aaa@jpeg "lemma:excalidraw")', + '', + '[Google](https://google.com)', + '', + '![Draw.io diagram](https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:x/bbb@jpeg "lemma:drawio")', + '', + '![plain](https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:x/ccc@jpeg)', + '', + 'Inline $$x^2$$ math.', + '', + '$$\\sum_i i$$', + '', + '```mermaid\ngraph TD\nA-->B\n```', +].join('\n') + +function FullHarness({ content }: { content: string }) { + const [show, setShow] = useState(true) + return ( +
+ + {show ? :

navigated away

} +
+ ) +} + +interface VariantProps { + withDragHandle?: boolean + withBubbleMenu?: boolean + withSlash?: boolean + withCodeBlockView?: boolean +} + +function VariantEditor({ withDragHandle, withBubbleMenu, withSlash, withCodeBlockView }: VariantProps) { + const editor = useEditor({ + extensions: [ + ...createLemmaExtensions( + withCodeBlockView + ? { + codeBlock: CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView) + }, + }).configure({ lowlight }), + } + : {}, + ), + MarkdownClipboard, + ...(withSlash ? [createSlashCommand(() => buildSlashItems(() => {}))] : []), + ], + content: '

hello world

', + }) + return ( +
+ {editor && withBubbleMenu && } + {editor && withDragHandle && ( + +
grip
+
+ )} + +
+ ) +} + +function VariantHarness(props: VariantProps) { + const [show, setShow] = useState(true) + return ( +
+ + {show ? :

navigated away

} +
+ ) +} + +async function expectNavigates() { + screen.getByText('navigate').click() + await waitFor(() => expect(screen.getByText('navigated away')).toBeInTheDocument(), { + timeout: 3000, + }) +} + +// Mirrors the real ArticleEditor structure: the editor arrives via +// React.lazy inside Suspense, and navigation goes through React Router. +const LazyTiptapArticleEditor = lazy(() => import('./TiptapArticleEditor')) + +function LazyEditScreen({ content }: { content: string }) { + const navigate = useNavigate() + return ( +
+ + + loading editor

}> + +
+
+ ) +} + +async function renderWithRouter(content: string) { + const router = createMemoryRouter([ + { path: '/', element: }, + { path: '/done', element:

navigated away

}, + ]) + render() + // Wait for the lazy editor to mount before navigating. + await waitFor(() => expect(document.querySelector('.ProseMirror')).toBeTruthy(), { + timeout: 3000, + }) +} + +describe('navigating away from the visual editor inside startTransition', () => { + it('A: full TiptapArticleEditor, trivial content', async () => { + render() + await expectNavigates() + }) + + it('A2: full TiptapArticleEditor, image/math/diagram content', async () => { + render() + await expectNavigates() + }) + + it('B: bare editor (no extras)', async () => { + render() + await expectNavigates() + }) + + it('C: bare editor + DragHandle', async () => { + render() + await expectNavigates() + }) + + it('D: bare editor + BubbleMenu', async () => { + render() + await expectNavigates() + }) + + it('E: bare editor + slash command', async () => { + render() + await expectNavigates() + }) + + it('F: bare editor + code block node view', async () => { + render() + await expectNavigates() + }) + + it('G: lazy editor + Suspense + react-router navigation, trivial content', async () => { + await renderWithRouter('hello world') + await expectNavigates() + }) + + it('H: lazy editor + Suspense + react-router navigation, rich content', async () => { + await renderWithRouter(RICH_MARKDOWN) + await expectNavigates() + }) + + it('I: async navigate after awaited work (mirrors save flow)', async () => { + await renderWithRouter(RICH_MARKDOWN) + screen.getByText('save then navigate').click() + await waitFor(() => expect(screen.getByText('navigated away')).toBeInTheDocument(), { + timeout: 3000, + }) + }) +}) diff --git a/app/components/editor/slash/SlashMenuList.tsx b/app/components/editor/slash/SlashMenuList.tsx new file mode 100644 index 0000000..597eb7d --- /dev/null +++ b/app/components/editor/slash/SlashMenuList.tsx @@ -0,0 +1,87 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' +import type { SlashItem } from './slash-items' + +export interface SlashMenuListHandle { + onKeyDown: (event: KeyboardEvent) => boolean +} + +interface SlashMenuListProps { + items: SlashItem[] + command: (item: SlashItem) => void +} + +// The floating list the slash command renders: arrow keys move the highlight, +// Enter or click runs the item. +const SlashMenuList = forwardRef( + ({ items, command }, ref) => { + const [selected, setSelected] = useState(0) + const listRef = useRef(null) + + useEffect(() => setSelected(0), [items]) + + useEffect(() => { + listRef.current + ?.querySelector('[data-selected="true"]') + ?.scrollIntoView({ block: 'nearest' }) + }, [selected]) + + useImperativeHandle( + ref, + () => ({ + onKeyDown: (event) => { + if (items.length === 0) return false + if (event.key === 'ArrowDown') { + setSelected((current) => (current + 1) % items.length) + return true + } + if (event.key === 'ArrowUp') { + setSelected((current) => (current - 1 + items.length) % items.length) + return true + } + if (event.key === 'Enter') { + command(items[selected]) + return true + } + return false + }, + }), + [items, selected, command], + ) + + return ( +
+ {items.length === 0 ? ( +

No matches

+ ) : ( + items.map((item, index) => { + const IconComponent = item.icon + return ( + + ) + }) + )} +
+ ) + }, +) + +SlashMenuList.displayName = 'SlashMenuList' + +export default SlashMenuList diff --git a/app/components/editor/slash/slash-command.ts b/app/components/editor/slash/slash-command.ts new file mode 100644 index 0000000..51317f0 --- /dev/null +++ b/app/components/editor/slash/slash-command.ts @@ -0,0 +1,77 @@ +import { Extension } from '@tiptap/core' +import { ReactRenderer } from '@tiptap/react' +import Suggestion from '@tiptap/suggestion' +import { computePosition, flip, offset, shift } from '@floating-ui/dom' +import SlashMenuList, { type SlashMenuListHandle } from './SlashMenuList' +import { filterSlashItems, type SlashItem } from './slash-items' + +// Typing "/" opens a filterable block-insert menu at the caret. The item list +// is supplied by the editor component (it owns the diagram-modal state some +// items need). +export function createSlashCommand(getItems: () => SlashItem[]) { + return Extension.create({ + name: 'slashCommand', + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + allowSpaces: false, + // Everything in a code fence is literal source. + allow: ({ editor }) => !editor.isActive('codeBlock'), + command: ({ editor, range, props }) => props.run({ editor, range }), + items: ({ query }) => filterSlashItems(getItems(), query), + render: () => { + let component: ReactRenderer | null = null + + const element = () => component?.element as HTMLElement | undefined + + const position = (clientRect?: (() => DOMRect | null) | null) => { + const el = element() + const rect = clientRect?.() + if (!el || !rect) return + computePosition( + { getBoundingClientRect: () => rect }, + el, + { placement: 'bottom-start', middleware: [offset(6), flip(), shift({ padding: 8 })] }, + ).then(({ x, y }) => { + Object.assign(el.style, { position: 'absolute', left: `${x}px`, top: `${y}px`, zIndex: '50' }) + }) + } + + return { + onStart: (props) => { + component = new ReactRenderer(SlashMenuList, { + props: { items: props.items, command: props.command }, + editor: props.editor, + }) + document.body.appendChild(component.element) + position(props.clientRect) + }, + onUpdate: (props) => { + component?.updateProps({ items: props.items, command: props.command }) + const el = element() + if (el) el.style.display = '' + position(props.clientRect) + }, + onKeyDown: (props) => { + if (props.event.key === 'Escape') { + const el = element() + if (el) el.style.display = 'none' + return true + } + return component?.ref?.onKeyDown(props.event) ?? false + }, + onExit: () => { + element()?.remove() + component?.destroy() + component = null + }, + } + }, + }), + ] + }, + }) +} diff --git a/app/components/editor/slash/slash-items.tsx b/app/components/editor/slash/slash-items.tsx new file mode 100644 index 0000000..affe4ac --- /dev/null +++ b/app/components/editor/slash/slash-items.tsx @@ -0,0 +1,109 @@ +import type { Range } from '@tiptap/core' +import type { Editor } from '@tiptap/react' +import { + ChartPieIcon, + ChatBubbleLeftRightIcon, + CodeBracketSquareIcon, + EyeSlashIcon, + FilmIcon, + GlobeAltIcon, + H1Icon, + H2Icon, + H3Icon, + ListBulletIcon, + MinusIcon, + MusicalNoteIcon, + NumberedListIcon, + PaintBrushIcon, + PresentationChartLineIcon, + QueueListIcon, + RectangleGroupIcon, + TableCellsIcon, + VariableIcon, +} from '@heroicons/react/24/outline' +import { markerForKind, type DiagramKind } from '../diagrams/diagram-source' +import { FENCE_TEMPLATES } from '../insert-helpers' + +export interface SlashItem { + title: string + keywords: string + icon: React.ComponentType<{ className?: string }> + run: (ctx: { editor: Editor; range: Range }) => void +} + +// The typed "/query" is deleted before each action runs. +function chainAfterDelete(editor: Editor, range: Range) { + return editor.chain().focus().deleteRange(range) +} + +function fenceItem( + title: string, + keywords: string, + icon: SlashItem['icon'], + fence: { language: string; template: string }, +): SlashItem { + return { + title, + keywords, + icon, + run: ({ editor, range }) => + chainAfterDelete(editor, range) + .insertContent({ + type: 'codeBlock', + attrs: { language: fence.language }, + content: [{ type: 'text', text: fence.template }], + }) + .run(), + } +} + +export function buildSlashItems(openDiagram: (kind: DiagramKind) => void): SlashItem[] { + return [ + { title: 'Heading 1', keywords: 'h1 title', icon: H1Icon, run: ({ editor, range }) => chainAfterDelete(editor, range).setNode('heading', { level: 1 }).run() }, + { title: 'Heading 2', keywords: 'h2 subtitle', icon: H2Icon, run: ({ editor, range }) => chainAfterDelete(editor, range).setNode('heading', { level: 2 }).run() }, + { title: 'Heading 3', keywords: 'h3', icon: H3Icon, run: ({ editor, range }) => chainAfterDelete(editor, range).setNode('heading', { level: 3 }).run() }, + { title: 'Bullet List', keywords: 'ul unordered', icon: ListBulletIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).toggleBulletList().run() }, + { title: 'Numbered List', keywords: 'ol ordered', icon: NumberedListIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).toggleOrderedList().run() }, + { title: 'Check List', keywords: 'task todo checkbox', icon: QueueListIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).toggleTaskList().run() }, + { title: 'Table', keywords: 'grid columns rows', icon: TableCellsIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, + { title: 'Blockquote', keywords: 'quote citation', icon: ChatBubbleLeftRightIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).toggleBlockquote().run() }, + { title: 'Horizontal Rule', keywords: 'hr divider separator', icon: MinusIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).setHorizontalRule().run() }, + fenceItem('Embed', 'bluesky youtube spotify iframe', GlobeAltIcon, FENCE_TEMPLATES.embed), + { title: 'Spoiler', keywords: 'hidden blur reveal', icon: EyeSlashIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).insertContent('::spoiler:: hidden text ::spoiler::').run() }, + fenceItem('Code Block', 'code snippet fence', CodeBracketSquareIcon, FENCE_TEMPLATES.code), + { title: 'Math Block', keywords: 'latex katex equation formula', icon: VariableIcon, run: ({ editor, range }) => chainAfterDelete(editor, range).insertMathBlock({ text: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}' }).run() }, + fenceItem('Function Plot', 'graph math plot chart', PresentationChartLineIcon, FENCE_TEMPLATES.functionplot), + fenceItem('Mermaid Diagram', 'flowchart graph chart', ChartPieIcon, FENCE_TEMPLATES.mermaid), + { + title: 'Excalidraw Drawing', + keywords: 'draw sketch whiteboard diagram', + icon: PaintBrushIcon, + run: ({ editor, range }) => { + chainAfterDelete(editor, range).run() + openDiagram('excalidraw') + }, + }, + { + title: 'Draw.io Diagram', + keywords: 'drawio diagrams.net flowchart', + icon: RectangleGroupIcon, + run: ({ editor, range }) => { + chainAfterDelete(editor, range).run() + openDiagram('drawio') + }, + }, + fenceItem('ABC Notation', 'music score notation', MusicalNoteIcon, FENCE_TEMPLATES.abc), + fenceItem('Fountain Screenplay', 'script movie film', FilmIcon, FENCE_TEMPLATES.fountain), + ] +} + +export function filterSlashItems(items: SlashItem[], query: string): SlashItem[] { + const q = query.toLowerCase().trim() + if (!q) return items + return items.filter( + (item) => item.title.toLowerCase().includes(q) || item.keywords.includes(q), + ) +} + +// Re-exported so slash items and modals agree on the marker constants. +export { markerForKind } diff --git a/app/routes/edit.tsx b/app/routes/edit.tsx index 8232d41..91821fa 100644 --- a/app/routes/edit.tsx +++ b/app/routes/edit.tsx @@ -150,6 +150,9 @@ function EditScreen({ session }: { session: NonNullable=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} engines: {node: '>=12'} @@ -4201,6 +4262,9 @@ packages: iso-datestring-validator@2.2.2: resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -4305,6 +4369,11 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -6004,9 +6073,19 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yjs@13.6.32: + resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -8950,10 +9029,34 @@ snapshots: dependencies: '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/extension-collaboration@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32)': + dependencies: + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 + '@tiptap/y-tiptap': 3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) + yjs: 13.6.32 + '@tiptap/extension-document@3.29.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))': dependencies: '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/extension-drag-handle-react@3.28.0(@tiptap/extension-drag-handle@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/extension-collaboration@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)))(@tiptap/pm@3.28.0)(@tiptap/react@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tiptap/extension-drag-handle': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/extension-collaboration@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)) + '@tiptap/pm': 3.28.0 + '@tiptap/react': 3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tiptap/extension-drag-handle@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/extension-collaboration@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32))(@tiptap/extension-node-range@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/extension-collaboration': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)(@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/extension-node-range': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 + '@tiptap/y-tiptap': 3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32) + '@tiptap/extension-dropcursor@3.29.2(@tiptap/extensions@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': dependencies: '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) @@ -9009,6 +9112,11 @@ snapshots: '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) '@tiptap/pm': 3.28.0 + '@tiptap/extension-node-range@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + dependencies: + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 + '@tiptap/extension-ordered-list@3.29.2(@tiptap/extension-list@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0))': dependencies: '@tiptap/extension-list': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) @@ -9099,6 +9207,21 @@ snapshots: '@tiptap/extensions': 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) '@tiptap/pm': 3.28.0 + '@tiptap/suggestion@3.28.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 + + '@tiptap/y-tiptap@3.0.8(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2)(y-protocols@1.0.7(yjs@13.6.32))(yjs@13.6.32)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.2 + y-protocols: 1.0.7(yjs@13.6.32) + yjs: 13.6.32 + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': dependencies: ejs: 3.1.10 @@ -10691,6 +10814,8 @@ snapshots: iso-datestring-validator@2.2.2: {} + isomorphic.js@0.2.5: {} + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -10802,6 +10927,10 @@ snapshots: leven@3.1.0: {} + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.32.0: optional: true @@ -12973,8 +13102,17 @@ snapshots: xmlchars@2.2.0: {} + y-protocols@1.0.7(yjs@13.6.32): + dependencies: + lib0: 0.2.117 + yjs: 13.6.32 + yallist@3.1.1: {} + yjs@13.6.32: + dependencies: + lib0: 0.2.117 + zod@3.25.76: {} zustand@4.5.7(@types/react@19.2.14)(react@19.2.6):