You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
392 lines
13 KiB
392 lines
13 KiB
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<HTMLDivElement>(null) |
|
const jsonHost = useRef<HTMLDivElement>(null) |
|
const markupView = useRef<EditorView | null>(null) |
|
const jsonView = useRef<EditorView | null>(null) |
|
const sliceRef = useRef<AdvancedEventLabSlice | null>(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<string | null>(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 ( |
|
<Dialog open={open} onOpenChange={onOpenChange}> |
|
<DialogContent className="z-[250] max-h-[92vh] w-[min(96vw,72rem)] flex flex-col gap-0 p-0 overflow-hidden"> |
|
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b"> |
|
<DialogTitle>{t('Advanced event lab')}</DialogTitle> |
|
<DialogDescription className="text-left"> |
|
{t('Advanced lab hint')} |
|
</DialogDescription> |
|
</DialogHeader> |
|
|
|
<div className="flex flex-col gap-2 px-4 py-2 border-b shrink-0 flex-wrap"> |
|
<div className="flex flex-wrap items-end gap-3"> |
|
{isLanguageToolConfigured() ? ( |
|
<div className="space-y-1 min-w-[10rem]"> |
|
<Label htmlFor="lt-lang">{t('Advanced lab grammar language')}</Label> |
|
<Select value={ltLang} onValueChange={setLtLang}> |
|
<SelectTrigger id="lt-lang" className="w-[220px]"> |
|
<SelectValue /> |
|
</SelectTrigger> |
|
<SelectContent className="max-h-64"> |
|
{ltList.map((code) => ( |
|
<SelectItem key={code} value={code}> |
|
{code} |
|
</SelectItem> |
|
))} |
|
</SelectContent> |
|
</Select> |
|
</div> |
|
) : null} |
|
{isTranslateConfigured() ? ( |
|
<div className="flex flex-wrap items-end gap-2"> |
|
<div className="space-y-1"> |
|
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label> |
|
<Input |
|
id="tr-tgt" |
|
className="w-24 font-mono text-sm" |
|
value={translateTarget} |
|
onChange={(e) => setTranslateTarget(e.target.value)} |
|
placeholder="en" |
|
/> |
|
</div> |
|
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}> |
|
{t('Advanced lab translate')} |
|
</Button> |
|
</div> |
|
) : null} |
|
{contextEventId && isTranslateConfigured() ? ( |
|
<Button type="button" variant="outline" size="sm" onClick={handleReadAloudBuffer}> |
|
{t('Advanced lab use translation read aloud')} |
|
</Button> |
|
) : null} |
|
</div> |
|
{jsonError ? ( |
|
<p className="text-sm text-destructive" role="alert"> |
|
{jsonError} |
|
</p> |
|
) : null} |
|
</div> |
|
|
|
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-2 gap-2 px-4 py-2 overflow-hidden"> |
|
<div className="flex flex-col min-h-0 gap-1"> |
|
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab markup')}</span> |
|
<div |
|
ref={markupHost} |
|
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20" |
|
/> |
|
</div> |
|
<div className="flex flex-col min-h-0 gap-1"> |
|
<span className="text-xs font-medium text-muted-foreground">{t('Advanced lab tags JSON')}</span> |
|
<div |
|
ref={jsonHost} |
|
className="flex-1 min-h-[200px] border rounded-md overflow-hidden bg-muted/20" |
|
/> |
|
</div> |
|
</div> |
|
|
|
<DialogFooter className="shrink-0 px-4 py-3 border-t gap-2"> |
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> |
|
{t('Cancel')} |
|
</Button> |
|
<Button type="button" onClick={handleApply} disabled={Boolean(jsonError)}> |
|
{t('Apply')} |
|
</Button> |
|
</DialogFooter> |
|
</DialogContent> |
|
</Dialog> |
|
) |
|
}
|
|
|