import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { isLanguageToolConfigured } from '@/lib/languagetool-client' import { languageToolLintExtension } from '@/lib/languagetool-cm-linter' import { buildLanguageToolPreferenceList } from '@/lib/languagetool-language-order' import { parseLabSlice, serializeLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { isTranslateConfigured, translatePlainText } from '@/lib/translate-client' import { setReadAloudTranslationForEvent } from '@/lib/read-aloud-translation-override' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { json } from '@codemirror/lang-json' import { StreamLanguage } from '@codemirror/language' import { asciidoc } from 'codemirror-asciidoc' import { EditorState, type Extension } from '@codemirror/state' import { oneDark } from '@codemirror/theme-one-dark' import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' export type AdvancedEventLabDialogProps = { open: boolean onOpenChange: (open: boolean) => void /** Snapshot when opening; parent should memoize. */ initial: AdvancedEventLabSlice | null /** When false, `kind` in JSON is shown but Apply forces `initial.kind`. */ kindEditable?: boolean markupMode: 'markdown' | 'asciidoc' /** `i18n.language` for LanguageTool default ordering. */ i18nLanguage?: string /** When set, user can store translation for read-aloud for this event id. */ contextEventId?: string | null onApply: (payload: AdvancedEventLabSlice) => void } function useDarkModeFlag(): boolean { const [dark, setDark] = useState(() => typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false ) useEffect(() => { const el = document.documentElement const obs = new MutationObserver(() => { setDark(el.classList.contains('dark')) }) obs.observe(el, { attributes: true, attributeFilter: ['class'] }) return () => obs.disconnect() }, []) return dark } export default function AdvancedEventLabDialog({ open, onOpenChange, initial, kindEditable = true, markupMode, i18nLanguage, contextEventId, onApply }: AdvancedEventLabDialogProps) { const { t, i18n } = useTranslation() const dark = useDarkModeFlag() const markupHost = useRef(null) const jsonHost = useRef(null) const markupView = useRef(null) const jsonView = useRef(null) const sliceRef = useRef(null) const syncing = useRef(false) const ltList = useMemo( () => buildLanguageToolPreferenceList(i18nLanguage ?? i18n.language), [i18nLanguage, i18n.language] ) const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US') const [jsonError, setJsonError] = useState(null) const [translateTarget, setTranslateTarget] = useState('en') useEffect(() => { if (open) { setLtLang(ltList[0] ?? 'en-US') } }, [open, ltList]) const destroyEditors = useCallback(() => { markupView.current?.destroy() jsonView.current?.destroy() markupView.current = null jsonView.current = null }, []) useEffect(() => { if (!open || !initial) { destroyEditors() return } const mkEl = markupHost.current const jsEl = jsonHost.current if (!mkEl || !jsEl) return destroyEditors() const baseSlice: AdvancedEventLabSlice = { kind: initial.kind, content: initial.content, tags: initial.tags.map((row) => [...row]) } sliceRef.current = baseSlice const markupLang: Extension = markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown() const mkExtensions: Extension[] = [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), cmPlaceholder(t('Advanced lab markup placeholder')), markupLang, EditorView.theme({ '&': { maxHeight: '100%' }, '.cm-scroller': { overflow: 'auto' }, '.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } }), EditorView.updateListener.of((update) => { if (!update.docChanged || syncing.current) return const content = update.state.doc.toString() const s = sliceRef.current if (!s) return s.content = content const jv = jsonView.current if (!jv) return const nextJson = serializeLabSlice({ kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), content: s.content, tags: s.tags }) if (jv.state.doc.toString() === nextJson) return syncing.current = true jv.dispatch({ changes: { from: 0, to: jv.state.doc.length, insert: nextJson }, selection: { anchor: 0 } }) syncing.current = false }) ] if (isLanguageToolConfigured()) { mkExtensions.push(languageToolLintExtension(ltLang, 450)) } if (dark) mkExtensions.push(oneDark) const jsonExtensions: Extension[] = [ history(), keymap.of([...defaultKeymap, ...historyKeymap]), lineNumbers(), json(), cmPlaceholder(t('Advanced lab json placeholder')), EditorView.theme({ '&': { maxHeight: '100%' }, '.cm-scroller': { overflow: 'auto' }, '.cm-content': { minHeight: '220px', fontFamily: 'var(--font-mono, ui-monospace, monospace)' } }), EditorView.updateListener.of((update) => { if (!update.docChanged || syncing.current) return const parsed = parseLabSlice(update.state.doc.toString()) if (!parsed.ok) { setJsonError(parsed.error) return } setJsonError(null) const fixedKind = kindEditable ? parsed.value.kind : (initial.kind ?? parsed.value.kind) const next: AdvancedEventLabSlice = { kind: fixedKind, content: parsed.value.content, tags: parsed.value.tags } sliceRef.current = next const mv = markupView.current if (mv && mv.state.doc.toString() !== next.content) { syncing.current = true mv.dispatch({ changes: { from: 0, to: mv.state.doc.length, insert: next.content }, selection: { anchor: Math.min(mv.state.selection.main.anchor, next.content.length) } }) syncing.current = false } }) ] if (dark) jsonExtensions.push(oneDark) const mkState = EditorState.create({ doc: baseSlice.content, extensions: mkExtensions }) const jsState = EditorState.create({ doc: serializeLabSlice({ kind: baseSlice.kind, content: baseSlice.content, tags: baseSlice.tags }), extensions: jsonExtensions }) markupView.current = new EditorView({ state: mkState, parent: mkEl }) jsonView.current = new EditorView({ state: jsState, parent: jsEl }) return destroyEditors }, [ open, initial, markupMode, ltLang, kindEditable, dark, destroyEditors, t ]) const handleApply = () => { const raw = jsonView.current?.state.doc.toString() ?? '' const parsed = parseLabSlice(raw) if (!parsed.ok) { toast.error(parsed.error) return } const kind = kindEditable ? parsed.value.kind : (initial?.kind ?? parsed.value.kind) const payload: AdvancedEventLabSlice = { kind, content: parsed.value.content, tags: parsed.value.tags } onApply(payload) onOpenChange(false) } const handleTranslate = async () => { if (!isTranslateConfigured()) { toast.message(t('Advanced lab translate not configured')) return } const text = markupView.current?.state.doc.toString() ?? sliceRef.current?.content ?? '' if (!text.trim()) return try { const out = await translatePlainText(text, translateTarget.trim() || 'en') if (!markupView.current) return syncing.current = true markupView.current.dispatch({ changes: { from: 0, to: markupView.current.state.doc.length, insert: out } }) syncing.current = false const s = sliceRef.current if (s) { s.content = out const jv = jsonView.current if (jv) { const nextJson = serializeLabSlice({ kind: kindEditable ? s.kind : (initial?.kind ?? s.kind), content: out, tags: s.tags }) syncing.current = true jv.dispatch({ changes: { from: 0, to: jv.state.doc.length, insert: nextJson } }) syncing.current = false } } toast.success(t('Advanced lab translate done')) } catch (e) { toast.error(e instanceof Error ? e.message : String(e)) } } const handleReadAloudBuffer = () => { if (!contextEventId) return const text = markupView.current?.state.doc.toString().trim() ?? '' if (!text) return setReadAloudTranslationForEvent(contextEventId, text) toast.success(t('Advanced lab read aloud buffer set')) } return ( {t('Advanced event lab')} {t('Advanced lab hint')}
{isLanguageToolConfigured() ? (
) : null} {isTranslateConfigured() ? (
setTranslateTarget(e.target.value)} placeholder="en" />
) : null} {contextEventId && isTranslateConfigured() ? ( ) : null}
{jsonError ? (

{jsonError}

) : null}
{t('Advanced lab markup')}
{t('Advanced lab tags JSON')}
) }