blog editor

This commit is contained in:
2026-04-24 11:58:19 +02:00
parent daab745c13
commit be6df0c8ad
16 changed files with 1448 additions and 559 deletions

View File

@@ -1,4 +1,3 @@
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
import { createContext, useContext, type ReactNode } from "react";
interface ToString {
@@ -8,7 +7,7 @@ interface ToString {
export interface MutationInterface {
mutate: (params:{id:string}) => void
mutate: (params: any) => void
error: ToString | null
status: "error" | "idle" | "pending" | "success"
}

View File

@@ -0,0 +1,224 @@
'use client'
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
export type InternalLinkSuggestion = {
label: string
href: string
group: string
}
export type MdeAutocompleteSuggestion = {
label: string
value: string
detail: string
group: string
trigger: string
}
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
export type AutocompleteTriggerConfig = {
trigger: string
label: string
isQueryValid?: (query: string) => boolean
}
type ActiveToken = {
start: number
end: number
query: string
trigger: MdeAutocompleteSuggestion['trigger']
}
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
{
trigger: '[[',
label: 'Internal links',
isQueryValid: (query) => !query.includes(']'),
},
{
trigger: '<',
label: 'MDX components',
isQueryValid: (query) => !/[\s>]/.test(query),
},
]
function findActiveToken(
value: string,
cursor: number,
triggerConfigs: AutocompleteTriggerConfig[],
): ActiveToken | null {
const beforeCursor = value.slice(0, cursor)
const activeTrigger = triggerConfigs
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
.filter((candidate) => candidate.start !== -1)
.sort((a, b) => b.start - a.start)[0]
if (!activeTrigger) return null
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
if (query.includes('\n')) return null
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
return {
start: activeTrigger.start,
end: cursor,
query,
trigger: activeTrigger.config.trigger,
}
}
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
return suggestions.map((suggestion) => ({
label: suggestion.label,
value: `[${suggestion.label}](${suggestion.href})`,
detail: suggestion.href,
group: suggestion.group,
trigger: '[[',
}))
}
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
suggestions: MdeAutocompleteSuggestion[]
triggerConfigs?: AutocompleteTriggerConfig[]
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [token, setToken] = useState<ActiveToken | null>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
function setRefs(element: HTMLTextAreaElement | null) {
textareaRef.current = element
if (typeof ref === 'function') ref(element)
else if (ref) ref.current = element
}
const resolvedTriggerConfigs = useMemo(() => {
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
const merged = new Map(configured.map((config) => [config.trigger, config]))
for (const suggestion of suggestions) {
if (!merged.has(suggestion.trigger)) {
merged.set(suggestion.trigger, {
trigger: suggestion.trigger,
label: suggestion.trigger,
})
}
}
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
}, [suggestions, triggerConfigs])
const triggerLabels = useMemo(
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
[resolvedTriggerConfigs],
)
const matches = useMemo(() => {
if (!token) return []
const query = token.query.toLowerCase()
return suggestions
.filter((suggestion) => suggestion.trigger === token.trigger)
.filter((suggestion) => {
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
return haystack.includes(query)
})
.slice(0, 8)
}, [suggestions, token])
function updateToken(textarea: HTMLTextAreaElement) {
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
setToken(nextToken)
setSelectedIndex(0)
}
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
if (!token) return
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
const insertedValue = markerIndex === -1
? suggestion.value
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
nativeInputValueSetter?.call(textarea, nextValue)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
textarea.setSelectionRange(cursor, cursor)
setToken(null)
setSelectedIndex(0)
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (token && matches.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex((index) => (index + 1) % matches.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const suggestion = matches[selectedIndex]
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setToken(null)
return
}
}
onKeyDown?.(event)
}
return (
<>
<textarea
{...props}
ref={setRefs}
value={value}
onChange={(event) => {
onChange?.(event)
updateToken(event.currentTarget)
}}
onClick={(event) => updateToken(event.currentTarget)}
onKeyUp={(event) => {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
updateToken(event.currentTarget)
}}
onKeyDown={handleKeyDown}
/>
{token && matches.length > 0 && (
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
</div>
<div className='max-h-64 overflow-y-auto py-1'>
{matches.map((suggestion, index) => (
<button
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
type='button'
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
}}
>
<span className='font-medium'>{suggestion.label}</span>
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
</button>
))}
</div>
</div>
)}
</>
)
})
InternalLinkTextarea.displayName = 'InternalLinkTextarea'

View File

@@ -1,25 +1,99 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import { Maximize2, Minimize2 } from "lucide-react";
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
import type { Control, FieldValues, Path } from "react-hook-form";
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import {
InternalLinkTextarea,
type AutocompleteTriggerConfig,
type MdeAutocompleteSuggestion,
} from "./InternalLinkTextarea";
export default function MdeFormField<T extends FieldValues>(params: {
control: Control<T>,
name: Path<T>,
label: string,
dataColorMode: "dark"|"light",
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
triggerConfigs?: AutocompleteTriggerConfig[],
renderPreview?: (source: string) => ReactElement,
}) {
const [fullscreen, setFullscreen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
if (!fullscreen) return
const originalOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = originalOverflow
}
}, [fullscreen])
return (
<FormField
control={params.control}
name={params.name}
render={({ field }) => (
<FormItem>
<FormLabel>
Description
</FormLabel>
<FormControl>
<MDEditor
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
/>
</FormControl>
</FormItem>
)}
render={({ field }) => {
const editor = (
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
<div className="flex shrink-0 items-center justify-between gap-2">
<FormLabel>
{params.label}
</FormLabel>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
onClick={() => setFullscreen((value) => !value)}
>
{fullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
</div>
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
<MDEditor
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
height={fullscreen ? "calc(100vh - 72px)" : undefined}
visibleDragbar={!fullscreen}
value={field.value ? field.value : ""}
onChange={field.onChange}
data-color-mode={params.dataColorMode}
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
components={{
textarea: (props) => (
<InternalLinkTextarea
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
suggestions={params.autocompleteSuggestions ?? []}
triggerConfigs={params.triggerConfigs}
/>
),
preview: params.renderPreview
? (source) => params.renderPreview?.(source) ?? <></>
: undefined,
}}
/>
</FormControl>
</FormItem>
)
if (fullscreen && mounted) {
return createPortal(editor, document.body)
}
return editor
}}
/>
)
}