'use client' import * as React from 'react' import type { ContentEditorSelectionRequest } from '@admin/utils/editor/content-editor-selection-request' import type { MarkdownHeadingLevel } from '@admin/utils/editor/markdown-heading-level' import type { MarkdownListType } from '@admin/utils/editor/markdown-list-type' import type { ViewUpdate } from '@codemirror/view' import type { QueryClient } from '@tanstack/react-query' import type { ReactCodeMirrorRef } from '@uiw/react-codemirror' import type { Root } from 'react-dom/client' import { MediaField } from '@admin/components/custom/media-field' import { applyCursorChange } from '@admin/utils/editor/apply-cursor-change' import { applyCursorChangeIfAvailable } from '@admin/utils/editor/apply-cursor-change-if-available' import { createMarkdownTableText } from '@admin/utils/editor/create-markdown-table-text' import { createMarkdownTextFromUrl } from '@admin/utils/editor/create-markdown-text-from-url' import { findMarkdownLinkRange } from '@admin/utils/editor/find-markdown-link-range' import { formatMarkdownBlockquoteLine } from '@admin/utils/editor/format-markdown-blockquote-line' import { formatMarkdownHeadingLine } from '@admin/utils/editor/format-markdown-heading-line' import { formatMarkdownListLine } from '@admin/utils/editor/format-markdown-list-line' import { getMarkdownHeadingFallback } from '@admin/utils/editor/get-markdown-heading-fallback' import { getMarkdownListFallback } from '@admin/utils/editor/get-markdown-list-fallback' import { insertComponentAtCursor } from '@admin/utils/editor/insert-component-at-cursor' import { insertMarkdownTextAtPosition } from '@admin/utils/editor/insert-markdown-text-at-position' import { insertTextAtCursor } from '@admin/utils/editor/insert-text-at-cursor' import { parseContentEditorMathInput } from '@admin/utils/editor/parse-content-editor-math-input' import { removeHighlightSpanAtCursor } from '@admin/utils/editor/remove-highlight-span-at-cursor' import { removeMarkdownLinkAtCursor } from '@admin/utils/editor/remove-markdown-link-at-cursor' import { sanitizeUrl } from '@admin/utils/editor/sanitize-url' import { setMarkdownLinkAtCursor } from '@admin/utils/editor/set-markdown-link-at-cursor' import { transformSelectedMarkdownLines } from '@admin/utils/editor/transform-selected-markdown-lines' import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { Decoration, EditorView, WidgetType } from '@codemirror/view' import { QueryClientProvider, useQueryClient } from '@tanstack/react-query' import { githubLightInit } from '@uiw/codemirror-theme-github' import { NuqsAdapter } from 'nuqs/adapters/react' import { createRoot } from 'react-dom/client' const ADMIN_MONO_FONT_FAMILY = 'var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace' const editorTheme = githubLightInit({ settings: { fontFamily: ADMIN_MONO_FONT_FAMILY } }) type MarkdownMediaGalleryWidgetProps = { widgetId: number disabled: boolean queryClient: QueryClient onChange: (url: string) => void onRemove: () => void } const markdownMediaGalleryRoots = new WeakMap() class MarkdownMediaGalleryWidget extends WidgetType { constructor(private readonly props: MarkdownMediaGalleryWidgetProps) { super() } eq(other: WidgetType) { if (!(other instanceof MarkdownMediaGalleryWidget)) return false return ( this.props.widgetId === other.props.widgetId && this.props.disabled === other.props.disabled && this.props.queryClient === other.props.queryClient && this.props.onChange === other.props.onChange && this.props.onRemove === other.props.onRemove ) } toDOM() { const container = document.createElement('div') container.className = 'cm-markdown-media-gallery' const root = createRoot(container) markdownMediaGalleryRoots.set(container, root) root.render( ) return container } updateDOM(container: HTMLElement) { const root = markdownMediaGalleryRoots.get(container) if (!root) return false root.render( ) return true } destroy(container: HTMLElement) { const root = markdownMediaGalleryRoots.get(container) markdownMediaGalleryRoots.delete(container) if (root) { queueMicrotask(() => root.unmount()) } } ignoreEvent() { return false } } export interface UseContentEditorSourceModeOptions { value: string onChange?: (value: string) => void disabled: boolean selectionRequest?: ContentEditorSelectionRequest | null onSelectionChange?: (offset: number) => void } export function useContentEditorSourceMode({ value, onChange, disabled, selectionRequest, onSelectionChange }: UseContentEditorSourceModeOptions) { const editorRef = React.useRef(null) const queryClient = useQueryClient() const mediaGalleryAnchorRef = React.useRef(null) const [mediaGalleryAnchor, setMediaGalleryAnchor] = React.useState<{ id: number pos: number } | null>(null) const [markdownLinkUrl, setMarkdownLinkUrl] = React.useState(null) const [isMarkdownLinkActive, setIsMarkdownLinkActive] = React.useState(false) const [canUndoMarkdownChange, setCanUndoMarkdownChange] = React.useState(false) const [canRedoMarkdownChange, setCanRedoMarkdownChange] = React.useState(false) const valueRef = React.useRef(value) valueRef.current = value const onChangeRef = React.useRef(onChange) onChangeRef.current = onChange const handleMediaSelected = React.useCallback( (url: string) => { if (disabled || !url) return const view = editorRef.current?.view ?? null const currentValue = view?.state.doc.toString() ?? valueRef.current ?? '' const markdownImages = createMarkdownTextFromUrl(url) const requestedPos = mediaGalleryAnchorRef.current ?? view?.state.selection.main.from ?? currentValue.length const { newValue, newCursorPos } = insertMarkdownTextAtPosition( currentValue, markdownImages, requestedPos ) if (view) { view.dispatch({ changes: { from: newCursorPos - markdownImages.length, insert: markdownImages }, selection: { anchor: newCursorPos } }) } onChangeRef.current?.(newValue) mediaGalleryAnchorRef.current = null setMediaGalleryAnchor(null) if (view) { applyCursorChange(view, newCursorPos) } }, [disabled] ) const handleMediaGalleryRemove = React.useCallback(() => { const view = editorRef.current?.view ?? null const cursorPos = mediaGalleryAnchorRef.current ?? view?.state.selection.main.from ?? 0 mediaGalleryAnchorRef.current = null setMediaGalleryAnchor(null) if (view) { applyCursorChange(view, cursorPos) } }, []) const getCurrentMarkdownLink = React.useCallback(() => { const view = editorRef.current?.view ?? null if (!view) return null const markdown = view.state.doc.toString() const { from, to } = view.state.selection.main return findMarkdownLinkRange(markdown, from, to) }, []) const syncMarkdownLinkState = React.useCallback((view: EditorView | null) => { if (!view) { setIsMarkdownLinkActive(false) return } const markdown = view.state.doc.toString() const { from, to } = view.state.selection.main const activeLink = findMarkdownLinkRange(markdown, from, to) setIsMarkdownLinkActive(Boolean(activeLink)) if (activeLink) { setMarkdownLinkUrl(activeLink.url) } }, []) const undoMarkdownChange = React.useCallback(() => { const view = editorRef.current?.view if (view) undo(view) }, []) const redoMarkdownChange = React.useCallback(() => { const view = editorRef.current?.view if (view) redo(view) }, []) const focusSourceSelection = React.useCallback( (view: EditorView, offset: number) => { const selectionOffset = Math.max(0, Math.min(offset, view.state.doc.length)) view.focus() view.dispatch({ selection: { anchor: selectionOffset }, effects: EditorView.scrollIntoView(selectionOffset, { y: 'center', yMargin: 48 }) }) onSelectionChange?.(selectionOffset) }, [onSelectionChange] ) const handleCreateEditor = React.useCallback( (view: EditorView) => { if (!selectionRequest) return focusSourceSelection(view, selectionRequest.offset) }, [focusSourceSelection, selectionRequest] ) React.useEffect(() => { const view = editorRef.current?.view if (!view || !selectionRequest) return focusSourceSelection(view, selectionRequest.offset) }, [focusSourceSelection, selectionRequest]) const openMediaGalleryAtCursor = React.useCallback(() => { if (disabled) return const view = editorRef.current?.view ?? null const pos = view?.state.selection.main.from ?? (value || '').length mediaGalleryAnchorRef.current = pos setMediaGalleryAnchor((current) => ({ id: (current?.id ?? 0) + 1, pos })) }, [disabled, value]) const handleMarkdownLinkPopoverOpenChange = React.useCallback( (isOpen: boolean) => { if (!isOpen) return const activeLink = getCurrentMarkdownLink() setMarkdownLinkUrl(activeLink?.url ?? '') setIsMarkdownLinkActive(Boolean(activeLink)) }, [getCurrentMarkdownLink] ) const handleEditorChange = React.useCallback( (nextValue: string, viewUpdate: ViewUpdate) => { const anchor = mediaGalleryAnchorRef.current if (anchor !== null && viewUpdate.docChanged) { const mappedAnchor = viewUpdate.changes.mapPos(anchor) mediaGalleryAnchorRef.current = mappedAnchor setMediaGalleryAnchor((current) => (current ? { ...current, pos: mappedAnchor } : current)) } onSelectionChange?.(viewUpdate.state.selection.main.from) onChange?.(nextValue) }, [onChange, onSelectionChange] ) const handleEditorUpdate = React.useCallback( (viewUpdate: ViewUpdate) => { setCanUndoMarkdownChange(undoDepth(viewUpdate.state) > 0) setCanRedoMarkdownChange(redoDepth(viewUpdate.state) > 0) if (!viewUpdate.selectionSet) return syncMarkdownLinkState(viewUpdate.view) onSelectionChange?.(viewUpdate.state.selection.main.from) }, [onSelectionChange, syncMarkdownLinkState] ) const mediaGalleryExtensions = React.useMemo(() => { if (!mediaGalleryAnchor) return [] return [ EditorView.decorations.of( Decoration.set([ Decoration.widget({ widget: new MarkdownMediaGalleryWidget({ widgetId: mediaGalleryAnchor.id, disabled, queryClient, onChange: handleMediaSelected, onRemove: handleMediaGalleryRemove }), block: true, side: 1 }).range(mediaGalleryAnchor.pos) ]) ) ] }, [disabled, handleMediaGalleryRemove, handleMediaSelected, mediaGalleryAnchor, queryClient]) const codeMirrorExtensions = React.useMemo( () => [ markdown(), EditorView.lineWrapping, EditorView.updateListener.of(handleEditorUpdate), EditorView.theme({ '&': { backgroundColor: 'var(--background)', color: 'var(--foreground)', display: 'flex', flex: '1', fontFamily: ADMIN_MONO_FONT_FAMILY, fontSize: '0.875rem', fontWeight: '400', lineHeight: '1.6', minHeight: '0', overflow: 'hidden' }, '& .cm-content': { backgroundColor: 'transparent', caretColor: 'var(--foreground)', color: 'var(--foreground)', fontFamily: ADMIN_MONO_FONT_FAMILY, minHeight: '100%', padding: '1.5rem' }, '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, & .cm-selectionLayer .cm-selectionBackground': { background: 'color-mix(in oklab, var(--primary) 20%, transparent) !important' }, '& .cm-line': { padding: '0' }, '& .cm-placeholder': { color: 'var(--muted-foreground)' }, '& .cm-gutters': { backgroundColor: 'var(--background)', borderColor: 'var(--border)', color: 'var(--muted-foreground)', fontFamily: ADMIN_MONO_FONT_FAMILY }, '& .cm-scroller': { backgroundColor: 'var(--background)', flex: '1', fontFamily: ADMIN_MONO_FONT_FAMILY, lineHeight: '1.6', minHeight: '0', overflow: 'auto' } }), ...mediaGalleryExtensions ], [handleEditorUpdate, mediaGalleryExtensions] ) const insertInline = React.useCallback( (before: string, after: string, placeholder: string) => { if (disabled) return const view = editorRef.current?.view ?? null const currentValue = value || '' const { newValue, newCursorPos } = insertTextAtCursor(view, currentValue, { before, after, placeholder }) onChange?.(newValue) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled, value, onChange] ) const insertBlock = React.useCallback( (block: string) => { if (disabled) return const view = editorRef.current?.view ?? null const currentValue = value || '' const { newValue, newCursorPos } = insertComponentAtCursor(view, currentValue, block) onChange?.(newValue) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled, value, onChange] ) const insertCodeBlock = React.useCallback(() => { insertInline('```\n', '\n```', 'code') }, [insertInline]) const insertDivider = React.useCallback(() => { insertBlock('---') }, [insertBlock]) const insertTable = React.useCallback( ({ cols, rows }: { cols: number; rows: number }) => { if (disabled) return const view = editorRef.current?.view ?? null const currentValue = view?.state.doc.toString() ?? valueRef.current ?? '' const table = createMarkdownTableText(rows, cols) const { newValue, newCursorPos } = insertComponentAtCursor(view, currentValue, table) onChangeRef.current?.(newValue) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled] ) const insertMath = React.useCallback( (input: string) => { if (disabled) return false const math = parseContentEditorMathInput(input) if (!math) return false const view = editorRef.current?.view ?? null const currentValue = view?.state.doc.toString() ?? valueRef.current ?? '' const insertValue = math.kind === 'display' ? `\n${math.markdown}\n` : math.markdown if (view) { const { from, to } = view.state.selection.main view.dispatch({ changes: { from, to, insert: insertValue }, selection: { anchor: from + insertValue.length } }) onChangeRef.current?.(currentValue.slice(0, from) + insertValue + currentValue.slice(to)) applyCursorChangeIfAvailable(view, from + insertValue.length) return true } onChangeRef.current?.(currentValue + insertValue) return true }, [disabled] ) const applyBold = React.useCallback(() => { insertInline('**', '**', 'bold text') }, [insertInline]) const applyItalic = React.useCallback(() => { insertInline('_', '_', 'italic text') }, [insertInline]) const applyStrike = React.useCallback(() => { insertInline('~~', '~~', 'strikethrough') }, [insertInline]) const applyCode = React.useCallback(() => { insertInline('`', '`', 'code') }, [insertInline]) const setMarkdownLink = React.useCallback(() => { if (disabled || !markdownLinkUrl) return const view = editorRef.current?.view ?? null const currentValue = view?.state.doc.toString() ?? valueRef.current ?? '' const { newValue, newCursorPos } = setMarkdownLinkAtCursor(view, currentValue, markdownLinkUrl) onChangeRef.current?.(newValue) setMarkdownLinkUrl('') setIsMarkdownLinkActive(true) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled, markdownLinkUrl]) const removeMarkdownLink = React.useCallback(() => { if (disabled) return const view = editorRef.current?.view ?? null const currentValue = view?.state.doc.toString() ?? valueRef.current ?? '' const { newValue, newCursorPos } = removeMarkdownLinkAtCursor(view, currentValue) onChangeRef.current?.(newValue) setMarkdownLinkUrl('') setIsMarkdownLinkActive(false) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled]) const openMarkdownLink = React.useCallback( (target: string = '_blank', features: string = 'noopener,noreferrer') => { const url = markdownLinkUrl || getCurrentMarkdownLink()?.url if (!url) return const safeUrl = sanitizeUrl(url, window.location.href) if (safeUrl !== '#') { window.open(safeUrl, target, features) } }, [getCurrentMarkdownLink, markdownLinkUrl] ) const applyUnderline = React.useCallback(() => { insertInline('', '', 'underlined text') }, [insertInline]) const applyHighlight = React.useCallback( (color: string) => { insertInline(``, '', 'highlighted text') }, [insertInline] ) const removeHighlight = React.useCallback(() => { if (disabled) return const view = editorRef.current?.view ?? null const currentValue = value || '' const { newValue, newCursorPos } = removeHighlightSpanAtCursor(view, currentValue) onChange?.(newValue) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled, value, onChange]) const transformLines = React.useCallback( (transformLine: (line: string, index: number) => string, fallbackLine: string) => { if (disabled) return const view = editorRef.current?.view ?? null const { newValue, newCursorPos } = transformSelectedMarkdownLines( view, value || '', transformLine, fallbackLine ) onChange?.(newValue) applyCursorChangeIfAvailable(view, newCursorPos) }, [disabled, value, onChange] ) const applyHeading = React.useCallback( (level: MarkdownHeadingLevel) => { transformLines( (line) => formatMarkdownHeadingLine(line, level), getMarkdownHeadingFallback(level) ) }, [transformLines] ) const applyList = React.useCallback( (type: MarkdownListType) => { transformLines( (line, index) => formatMarkdownListLine(line, index, type), getMarkdownListFallback(type) ) }, [transformLines] ) const applyBlockquote = React.useCallback(() => { transformLines(formatMarkdownBlockquoteLine, '> Quote') }, [transformLines]) return { applyBlockquote, applyBold, applyCode, applyHeading, applyItalic, applyList, applyHighlight, applyStrike, applyUnderline, canRedoMarkdownChange, canUndoMarkdownChange, codeMirrorExtensions, editorRef, editorTheme, handleCreateEditor, handleEditorChange, handleMarkdownLinkPopoverOpenChange, isMarkdownLinkActive, insertCodeBlock, insertDivider, insertMath, insertTable, markdownLinkUrl: markdownLinkUrl || '', openMediaGalleryAtCursor, openMarkdownLink, redoMarkdownChange, removeHighlight, removeMarkdownLink, setMarkdownLink, setMarkdownLinkUrl, undoMarkdownChange } }