diff --git a/app/components/editor/ArticleEditor.tsx b/app/components/editor/ArticleEditor.tsx new file mode 100644 index 0000000..0db5718 --- /dev/null +++ b/app/components/editor/ArticleEditor.tsx @@ -0,0 +1,188 @@ +import { lazy, Suspense, useEffect, useRef, useState } from 'react' +import CodeMirrorMarkdownEditor from '~/components/CodeMirrorMarkdownEditor' +import Spinner from '~/components/Spinner' +import type { LossyReport } from '~/lib/tiptap/markdown/lossy-detect' +import type { TiptapArticleEditorApi } from './TiptapArticleEditor' + +// The visual editor (Tiptap + serializer + KaTeX + lowlight) lives in its own +// chunk; users who stay on the markdown editor never download it. +const TiptapArticleEditor = lazy(() => import('./TiptapArticleEditor')) + +const STORAGE_KEY = 'lemma:editor-mode' + +type EditorMode = 'markdown' | 'visual' + +export interface ArticleEditorApi { + getMarkdown(): string +} + +interface ArticleEditorProps { + initialValue?: string + onChange?: (value: string) => void + apiRef?: React.MutableRefObject +} + +function storedMode(): EditorMode { + try { + return localStorage.getItem(STORAGE_KEY) === 'visual' ? 'visual' : 'markdown' + } catch { + return 'markdown' + } +} + +// Both editors speak the same language — the raw markdown that lemma stores — +// so switching hands the current markdown to the other editor. The record +// produced on save is identical regardless of which editor wrote it. +export default function ArticleEditor({ initialValue = '', onChange, apiRef }: ArticleEditorProps) { + const [mode, setMode] = useState(storedMode) + // The markdown the active editor was seeded with; bumping generation + // remounts the editor with the latest seed on switch. + const [seed, setSeed] = useState(initialValue) + const [generation, setGeneration] = useState(0) + const [lossyWarning, setLossyWarning] = useState(null) + const currentMarkdownRef = useRef(initialValue) + const tiptapApiRef = useRef(null) + + const getMarkdown = () => + mode === 'visual' && tiptapApiRef.current + ? tiptapApiRef.current.getMarkdown() + : currentMarkdownRef.current + + const getMarkdownRef = useRef(getMarkdown) + getMarkdownRef.current = getMarkdown + + useEffect(() => { + if (!apiRef) return + apiRef.current = { getMarkdown: () => getMarkdownRef.current() } + return () => { + apiRef.current = null + } + }, [apiRef]) + + // An article opened directly in visual mode (persisted preference) gets the + // same lossy-content check a mid-session switch does. + useEffect(() => { + if (mode !== 'visual' || !initialValue.trim()) return + let cancelled = false + import('~/lib/tiptap/markdown/lossy-detect') + .then(({ detectLossyMarkdown }) => { + if (cancelled) return + const report = detectLossyMarkdown(initialValue) + if (report.isLossy) setLossyWarning(report) + }) + .catch(() => { + // Detection is advisory only. + }) + return () => { + cancelled = true + } + // Mount-time check only — switches run their own detection in switchTo. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function handleChange(value: string) { + currentMarkdownRef.current = value + onChange?.(value) + } + + async function switchTo(next: EditorMode) { + if (next === mode) return + const markdown = getMarkdown() + currentMarkdownRef.current = markdown + setLossyWarning(null) + if (next === 'visual' && markdown.trim()) { + // The detector lives in the visual-editor chunk, which this switch is + // about to load anyway. + try { + const { detectLossyMarkdown } = await import('~/lib/tiptap/markdown/lossy-detect') + const report = detectLossyMarkdown(markdown) + if (report.isLossy) setLossyWarning(report) + } catch { + // Detection is advisory; never block the switch on it. + } + } + setSeed(markdown) + setMode(next) + setGeneration((g) => g + 1) + try { + localStorage.setItem(STORAGE_KEY, next) + } catch { + // Private browsing or storage quota — the toggle still works this session. + } + } + + return ( +
+
+
+ {(['markdown', 'visual'] as const).map((m) => ( + + ))} +
+
+ + {lossyWarning && mode === 'visual' && ( +
+

+ This article contains formatting the visual editor can't fully represent + {lossyWarning.hasRawHtml && ' (raw HTML)'} + {lossyWarning.hasFootnotes && ' (footnotes)'}. Saving from the visual editor will + rewrite or drop it. +

+
+ + +
+
+ )} + + {mode === 'visual' ? ( + + +
+ } + > + + + ) : ( + + )} + + ) +} diff --git a/app/components/editor/TiptapArticleEditor.tsx b/app/components/editor/TiptapArticleEditor.tsx new file mode 100644 index 0000000..8e279b2 --- /dev/null +++ b/app/components/editor/TiptapArticleEditor.tsx @@ -0,0 +1,150 @@ +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' +import { Image } from '@tiptap/extension-image' +import { createLemmaExtensions, lowlight } from '~/lib/tiptap/extensions' +import { MathBlock } from '~/lib/tiptap/math-block' +import { MathInline } from '~/lib/tiptap/math-inline' +import { MarkdownClipboard } from '~/lib/tiptap/markdown-clipboard' +import { editorHtmlToMarkdown, markdownToEditorHtml } from '~/lib/tiptap/markdown/serializer' +import CodeBlockView from './nodeviews/CodeBlockView' +import ImageView from './nodeviews/ImageView' +import MathBlockView from './nodeviews/MathBlockView' +import MathInlineView from './nodeviews/MathInlineView' +import TiptapToolbar from './TiptapToolbar' +import './tiptap-editor.css' + +export interface TiptapArticleEditorApi { + getMarkdown(): string +} + +interface TiptapArticleEditorProps { + initialValue?: string + onChange?: (markdown: string) => void + showToolbar?: boolean + apiRef?: React.MutableRefObject +} + +// Serializing to markdown walks the whole document, so onChange is debounced +// rather than per-keystroke. Consumers that need the exact current value at a +// point in time (submit, editor switch) use apiRef.getMarkdown(), which +// serializes synchronously. +const CHANGE_DEBOUNCE_MS = 300 + +export default function TiptapArticleEditor({ + initialValue = '', + onChange, + showToolbar = true, + apiRef, +}: TiptapArticleEditorProps) { + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const debounceRef = useRef | null>(null) + const [imageError, setImageError] = useState(null) + const imageErrorTimerRef = useRef | null>(null) + + const extensions = useMemo( + () => [ + ...createLemmaExtensions({ + codeBlock: CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView) + }, + }).configure({ lowlight }), + mathInline: MathInline.extend({ + addNodeView() { + return ReactNodeViewRenderer(MathInlineView) + }, + }), + mathBlock: MathBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(MathBlockView) + }, + }), + image: Image.extend({ + addNodeView() { + return ReactNodeViewRenderer(ImageView) + }, + }).configure({ inline: true }), + }), + Placeholder.configure({ placeholder: 'Write your article…' }), + MarkdownClipboard, + ], + [], + ) + + const serialize = useCallback((instance: Editor) => editorHtmlToMarkdown(instance.getHTML()), []) + + const editor = useEditor({ + extensions, + content: markdownToEditorHtml(initialValue), + onUpdate: ({ editor: instance }) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => { + debounceRef.current = null + onChangeRef.current?.(serialize(instance)) + }, CHANGE_DEBOUNCE_MS) + }, + onBlur: ({ editor: instance }) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + debounceRef.current = null + } + onChangeRef.current?.(serialize(instance)) + }, + }) + + useEffect(() => { + if (!apiRef) return + apiRef.current = { + getMarkdown: () => (editor && !editor.isDestroyed ? serialize(editor) : initialValue), + } + return () => { + apiRef.current = null + } + }, [apiRef, editor, serialize, initialValue]) + + // Flush a pending debounced change on unmount. Effect cleanups run in + // reverse registration order, so the editor is not yet destroyed here. + useEffect(() => { + return () => { + if (debounceRef.current && editor && !editor.isDestroyed) { + clearTimeout(debounceRef.current) + debounceRef.current = null + onChangeRef.current?.(serialize(editor)) + } + } + }, [editor, serialize]) + + function handleImageError(message: string) { + setImageError(message) + if (imageErrorTimerRef.current) clearTimeout(imageErrorTimerRef.current) + imageErrorTimerRef.current = setTimeout(() => setImageError(null), 4000) + } + + return ( +
+ {showToolbar && } + {imageError && ( +
+

{imageError}

+ +
+ )} + +
+ ) +} diff --git a/app/components/editor/TiptapToolbar.tsx b/app/components/editor/TiptapToolbar.tsx new file mode 100644 index 0000000..9611664 --- /dev/null +++ b/app/components/editor/TiptapToolbar.tsx @@ -0,0 +1,216 @@ +import React, { lazy, Suspense, useEffect, useReducer, useRef, useState } from 'react' +import type { Editor } from '@tiptap/react' +import { + BoldIcon, + ItalicIcon, + CodeBracketIcon, + H1Icon, H2Icon, H3Icon, + ListBulletIcon, + NumberedListIcon, + QueueListIcon, + ChatBubbleLeftRightIcon, + LinkIcon, + PhotoIcon, + GlobeAltIcon, + CodeBracketSquareIcon, + TableCellsIcon, + VariableIcon, + ChartPieIcon, + MusicalNoteIcon, + FilmIcon, + PresentationChartLineIcon, + MinusIcon, + EyeSlashIcon, + StrikethroughIcon, + PaintBrushIcon, + RectangleGroupIcon, +} from '@heroicons/react/24/outline' + +import { MAX_IMAGE_BYTES, MAX_IMAGE_LABEL } from '~/lib/security-constants' +import { compressImage } from '~/lib/compress-image' +import { markerForKind, type DiagramKind } from './diagrams/diagram-source' + +const ExcalidrawModal = lazy(() => import('./diagrams/ExcalidrawModal')) +const DrawioModal = lazy(() => import('./diagrams/DrawioModal')) + +interface ToolbarProps { + editor: Editor | null + onImageError?: (message: string) => void +} + +interface ToolbarAction { + id: string + label: string + icon: React.ComponentType<{ className?: string }> + action: () => void + isActive?: () => boolean + separator?: boolean + 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 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) + + useEffect(() => { + if (!editor) return + editor.on('transaction', bump) + return () => { + editor.off('transaction', bump) + } + }, [editor]) + + if (!editor) return null + + const editLink = () => { + const previous = editor.getAttributes('link').href as string | undefined + const url = window.prompt('Link URL', previous ?? 'https://') + if (url === null) return + if (url === '' || url === 'https://') { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + return + } + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + } + + const actions: ToolbarAction[] = [ + { id: 'h1', label: 'Heading 1', icon: H1Icon, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: () => editor.isActive('heading', { level: 1 }) }, + { id: 'h2', label: 'Heading 2', icon: H2Icon, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => editor.isActive('heading', { level: 2 }) }, + { id: 'h3', label: 'Heading 3', icon: H3Icon, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), isActive: () => editor.isActive('heading', { level: 3 }) }, + { id: 'bold', label: 'Bold', icon: BoldIcon, shortcut: 'Cmd+B', action: () => editor.chain().focus().toggleBold().run(), isActive: () => editor.isActive('bold') }, + { id: 'italic', label: 'Italic', icon: ItalicIcon, shortcut: 'Cmd+I', action: () => editor.chain().focus().toggleItalic().run(), isActive: () => editor.isActive('italic') }, + { id: 'strikethrough', label: 'Strikethrough', icon: StrikethroughIcon, action: () => editor.chain().focus().toggleStrike().run(), isActive: () => editor.isActive('strike') }, + { id: 'ul', label: 'Unordered List', icon: ListBulletIcon, action: () => editor.chain().focus().toggleBulletList().run(), isActive: () => editor.isActive('bulletList') }, + { id: 'ol', label: 'Ordered List', icon: NumberedListIcon, action: () => editor.chain().focus().toggleOrderedList().run(), isActive: () => editor.isActive('orderedList') }, + { id: 'cl', label: 'Check List', icon: QueueListIcon, action: () => editor.chain().focus().toggleTaskList().run(), isActive: () => editor.isActive('taskList') }, + // Always insert a header row — GFM tables require one to serialize. + { id: 'table', label: 'Table', icon: TableCellsIcon, action: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), isActive: () => editor.isActive('table') }, + { id: 'quote', label: 'Blockquote', icon: ChatBubbleLeftRightIcon, action: () => editor.chain().focus().toggleBlockquote().run(), isActive: () => editor.isActive('blockquote') }, + { id: 'hr', label: 'Horizontal Rule', icon: MinusIcon, action: () => editor.chain().focus().setHorizontalRule().run() }, + { 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: '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: '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.') }, + ] + + return ( +
+ { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + try { + const compressed = await compressImage(file) + if (compressed.size > MAX_IMAGE_BYTES) { + onImageError?.(`Image exceeds ${MAX_IMAGE_LABEL} even after compression. Please use a smaller image.`) + return + } + const url = URL.createObjectURL(compressed) + const alt = file.name.replace(/\.[^.]+$/, '') + editor.chain().focus().setImage({ src: url, alt }).run() + } catch { + onImageError?.('Image could not be processed. Please try a different file.') + } + }} + /> + {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 ( +
+ ) + } + + const IconComponent = action.icon + const isUndersizedIcon = + IconComponent === CodeBracketIcon || + IconComponent === CodeBracketSquareIcon || + IconComponent === VariableIcon || + IconComponent === PresentationChartLineIcon || + IconComponent === ChartPieIcon || + IconComponent === MusicalNoteIcon || + IconComponent === FilmIcon + const active = action.isActive?.() ?? false + + return ( + + ) + })} +
+ ) +} + +export default TiptapToolbar diff --git a/app/components/editor/diagrams/DrawioModal.tsx b/app/components/editor/diagrams/DrawioModal.tsx new file mode 100644 index 0000000..14c5c51 --- /dev/null +++ b/app/components/editor/diagrams/DrawioModal.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { DrawIoEmbed, type EventExit, type EventSave } from 'react-drawio' +import Spinner from '~/components/Spinner' +import { blobToDataUri, dataUriToBlob, fetchDiagramSourceBlob } from './diagram-source' + +// The Draw.io editor is loaded as an iframe from this host (there is no +// self-hostable npm build); the diagram itself never leaves the browser +// except through lemma's normal blob upload. +const DRAWIO_EMBED_URL = 'https://embed.diagrams.net' + +interface DrawioModalProps { + // Existing diagram image to load (object URL or CDN URL); null starts blank. + initialSrc: string | null + // Returns an error message to show (modal stays open), or null on success + // (host closes the modal). + onCommit: (blob: Blob) => string | null + onClose: () => void +} + +export default function DrawioModal({ initialSrc, onCommit, onClose }: DrawioModalProps) { + // '' renders a blank editor; null means the existing diagram is still loading. + const [initialDataUri, setInitialDataUri] = useState(initialSrc ? null : '') + const [error, setError] = useState(null) + + const isDark = document.documentElement.classList.contains('dark') + + useEffect(() => { + if (!initialSrc) return + let cancelled = false + ;(async () => { + try { + const blob = await fetchDiagramSourceBlob(initialSrc) + // With exportFormat="xmlpng" the embed accepts the PNG data URI + // directly and reads the XML Draw.io embeds in it. + const uri = await blobToDataUri(blob) + if (!cancelled) setInitialDataUri(uri) + } catch { + if (!cancelled) { + setError("Couldn't load the diagram source. You can draw a replacement from scratch.") + setInitialDataUri('') + } + } + })() + return () => { + cancelled = true + } + }, [initialSrc]) + + return ( +
+
+
+

Draw.io

+ {error &&

{error}

} + +
+
+ {initialDataUri === null ? ( +
+ +
+ ) : ( + { + // Saves arrive as an xmlpng export: a PNG data URI with the + // diagram XML embedded, so the file stays editable. + if (data.parentEvent !== 'save') return + let blob: Blob + try { + blob = dataUriToBlob(data.xml) + } catch { + setError('Could not export the diagram.') + return + } + const commitError = onCommit(blob) + if (commitError) setError(commitError) + }} + onClose={(data: EventExit) => { + // Ignore the synthetic exit that follows a save — the host + // closes the modal itself on successful commit. + if (!data.parentEvent) onClose() + }} + /> + )} +
+
+
+ ) +} diff --git a/app/components/editor/diagrams/ExcalidrawModal.tsx b/app/components/editor/diagrams/ExcalidrawModal.tsx new file mode 100644 index 0000000..ed7c74e --- /dev/null +++ b/app/components/editor/diagrams/ExcalidrawModal.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { Excalidraw, exportToBlob, loadFromBlob } from '@excalidraw/excalidraw' +import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types' +import '@excalidraw/excalidraw/index.css' +import Spinner from '~/components/Spinner' +import { fetchDiagramSourceBlob } from './diagram-source' + +interface ExcalidrawModalProps { + // Existing diagram image to load (object URL or CDN URL); null starts blank. + initialSrc: string | null + // Returns an error message to show (modal stays open), or null on success + // (host closes the modal). + onCommit: (blob: Blob) => string | null + onClose: () => void +} + +export default function ExcalidrawModal({ initialSrc, onCommit, onClose }: ExcalidrawModalProps) { + const [api, setApi] = useState(null) + const [initialData, setInitialData] = useState> | null>(null) + const [loading, setLoading] = useState(Boolean(initialSrc)) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + const isDark = document.documentElement.classList.contains('dark') + + useEffect(() => { + if (!initialSrc) return + let cancelled = false + ;(async () => { + try { + const blob = await fetchDiagramSourceBlob(initialSrc) + // loadFromBlob reads the scene JSON Excalidraw embeds in exported PNGs + const scene = await loadFromBlob(blob, null, null) + if (!cancelled) setInitialData(scene) + } catch { + if (!cancelled) { + setError("Couldn't load the diagram source. You can draw a replacement from scratch.") + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [initialSrc]) + + async function handleSave() { + if (!api) return + setSaving(true) + setError(null) + try { + // exportEmbedScene stores the scene JSON inside the PNG so the diagram + // stays editable; exportBackground avoids transparent-PNG artifacts when + // the CDN re-encodes to JPEG for display. + const blob = await exportToBlob({ + elements: api.getSceneElements(), + appState: { exportEmbedScene: true, exportBackground: true, exportWithDarkMode: false }, + files: api.getFiles(), + mimeType: 'image/png', + }) + const commitError = onCommit(blob) + if (commitError) setError(commitError) + } catch { + setError('Could not export the diagram.') + } finally { + setSaving(false) + } + } + + return ( +
+
+
+

Excalidraw

+ {error &&

{error}

} +
+ + +
+
+
+ {loading ? ( +
+ +
+ ) : ( + setApi(instance)} + initialData={initialData ? { ...initialData, scrollToContent: true } : null} + theme={isDark ? 'dark' : 'light'} + /> + )} +
+
+
+ ) +} diff --git a/app/components/editor/diagrams/diagram-source.ts b/app/components/editor/diagrams/diagram-source.ts new file mode 100644 index 0000000..d9cc2b5 --- /dev/null +++ b/app/components/editor/diagrams/diagram-source.ts @@ -0,0 +1,66 @@ +import { resolvePdsUrl } from '~/lib/atproto' + +// Diagrams are stored as ordinary markdown images whose blob is an exported +// PNG carrying its own source (Excalidraw scene JSON / Draw.io XML embedded in +// the file). The image *title* marks which diagram editor owns it: +// +// ![Excalidraw diagram](url "lemma:excalidraw") +// +// The record shape is identical to any image-bearing article; the marker is +// plain markdown that survives both editors and the published renderer. +export const EXCALIDRAW_MARKER = 'lemma:excalidraw' +export const DRAWIO_MARKER = 'lemma:drawio' + +export type DiagramKind = 'excalidraw' | 'drawio' + +export function diagramKindFromTitle(title: string | null | undefined): DiagramKind | null { + if (title === EXCALIDRAW_MARKER) return 'excalidraw' + if (title === DRAWIO_MARKER) return 'drawio' + return null +} + +export function markerForKind(kind: DiagramKind): string { + return kind === 'excalidraw' ? EXCALIDRAW_MARKER : DRAWIO_MARKER +} + +const CDN_IMG_RE = /cdn\.bsky\.app\/img\/[^/]+\/plain\/(did:[a-zA-Z0-9:._%-]+)\/([A-Za-z0-9]+)@/ + +// The original bytes of a diagram file. Pre-publish the src is an object URL +// whose blob still carries the embedded source. Post-publish the src is a +// Bluesky CDN URL whose bytes are re-encoded (embedded source stripped), so +// the original blob is fetched from the author's PDS via +// com.atproto.sync.getBlob — the CID is right in the CDN URL. +export async function fetchDiagramSourceBlob(src: string): Promise { + if (src.startsWith('blob:')) { + const res = await fetch(src) + return res.blob() + } + const match = CDN_IMG_RE.exec(src) + if (!match) throw new Error('This image is not an editable diagram.') + const did = decodeURIComponent(match[1]) + const cid = match[2] + const pds = await resolvePdsUrl(did) + const res = await fetch( + `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${cid}`, + ) + if (!res.ok) throw new Error(`Could not fetch the diagram source (${res.status}).`) + return res.blob() +} + +export function blobToDataUri(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +export function dataUriToBlob(dataUri: string): Blob { + const [header, base64] = dataUri.split(',') + const mime = header.match(/data:([^;]+)/)?.[1] ?? 'image/png' + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: mime }) +} diff --git a/app/components/editor/nodeviews/CodeBlockView.tsx b/app/components/editor/nodeviews/CodeBlockView.tsx new file mode 100644 index 0000000..c32cf3c --- /dev/null +++ b/app/components/editor/nodeviews/CodeBlockView.tsx @@ -0,0 +1,37 @@ +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from '@tiptap/react' +import { lowlight } from '~/lib/tiptap/extensions' +import MermaidPreview from './MermaidPreview' + +const LANGUAGES: string[] = (lowlight.listLanguages() as string[]).slice().sort() + +// Code block with a language picker; ```mermaid blocks also render a live +// preview of the diagram beneath the source. +export default function CodeBlockView({ node, updateAttributes, editor }: NodeViewProps) { + const language: string | null = node.attrs.language ?? null + + return ( + + +
+         as="code" className="font-mono text-sm" />
+      
+ {language === 'mermaid' && } +
+ ) +} diff --git a/app/components/editor/nodeviews/ImageView.tsx b/app/components/editor/nodeviews/ImageView.tsx new file mode 100644 index 0000000..10bec1e --- /dev/null +++ b/app/components/editor/nodeviews/ImageView.tsx @@ -0,0 +1,69 @@ +import { lazy, Suspense, 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')) + +// Renders a content image. Images whose markdown title carries a `lemma:` +// diagram marker double-click into the matching diagram editor; everything +// else is a plain . +export default function ImageView({ node, updateAttributes, editor, selected }: NodeViewProps) { + const { src, alt, title } = node.attrs as { src: string; alt?: string; title?: string } + const kind = diagramKindFromTitle(title) + const [openEditor, setOpenEditor] = useState(null) + const [error, setError] = useState(null) + + const selectedClass = selected ? 'outline outline-2 outline-blue-600 outline-offset-2' : '' + + if (!kind) { + return ( + + {alt + + ) + } + + // The old object URL / CDN URL is intentionally not revoked on commit: it + // may still be referenced by the markdown editor's preview or an unsaved + // draft, and navigation frees object URLs anyway. + const commit = (blob: Blob): string | null => { + if (blob.size > MAX_IMAGE_BYTES) { + return `Diagram exceeds ${MAX_IMAGE_LABEL}. Simplify it or split it up.` + } + updateAttributes({ src: URL.createObjectURL(blob) }) + setOpenEditor(null) + setError(null) + return null + } + + return ( + + { + if (editor.isEditable) setOpenEditor(kind) + }} + title="Double-click to edit diagram" + > + {alt + {editor.isEditable && ( + + Double-click to edit {kind === 'excalidraw' ? 'Excalidraw' : 'Draw.io'} diagram + + )} + + {error && {error}} + {openEditor && ( + + {openEditor === 'excalidraw' ? ( + setOpenEditor(null)} /> + ) : ( + setOpenEditor(null)} /> + )} + + )} + + ) +} diff --git a/app/components/editor/nodeviews/MathBlockView.tsx b/app/components/editor/nodeviews/MathBlockView.tsx new file mode 100644 index 0000000..0b2b197 --- /dev/null +++ b/app/components/editor/nodeviews/MathBlockView.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from 'react' +import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react' +import { katexHtml } from './katex-html' + +// Renders display math with KaTeX; double-click opens a textarea for editing +// the LaTeX source with a live preview. Escape cancels, Done commits. +export default function MathBlockView({ node, updateAttributes, editor }: NodeViewProps) { + const text: string = node.attrs.text ?? '' + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(text) + const textareaRef = useRef(null) + + useEffect(() => { + if (editing) textareaRef.current?.focus() + }, [editing]) + + const commit = () => { + setEditing(false) + const value = draft.trim() + if (value && value !== text) updateAttributes({ text: value }) + else setDraft(text) + } + + const rendered = (source: string) => katexHtml(source, true) + + if (editing) { + return ( + +
+