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.
 
 
 
 

1080 lines
40 KiB

import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import logger from '@/lib/logger'
import { isLanguageToolConfigured } from '@/lib/languagetool-client'
import { languageToolLintExtension, requestAdvancedLabGrammarLint } from '@/lib/languagetool-cm-linter'
import { pickLanguageToolCodeForTranslateTarget } from '@/lib/languagetool-language-order'
import {
buildResolvedTranslateMenuLanguageOptions,
translateLanguageOptionMatchesQuery
} from '@/lib/language-display-meta'
import { LanguageSelectOptionLines } from '@/lib/language-select-option-lines'
import { buildLabLanguageToolPreferenceList } from '@/lib/trinity-languages'
import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import {
fetchTranslateLanguages,
isTranslateConfigured,
translateApiLanguageCode,
type TranslateLanguageOption
} 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 { StreamLanguage } from '@codemirror/language'
import { asciidoc } from 'codemirror-asciidoc'
import { EditorSelection, EditorState, type Extension } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import {
EditorView,
keymap,
lineNumbers,
placeholder as cmPlaceholder
} from '@codemirror/view'
import { Undo2 } from 'lucide-react'
import type { MutableRefObject, ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AdvancedEventLabMarkupToolbar } from './AdvancedEventLabMarkupToolbar'
import { AdvancedEventLabPreviewPane } from './AdvancedEventLabPreviewPane'
import customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service'
import type { TEmoji } from '@/types'
const PREVIEW_DEBOUNCE_MS = 200
const LAB_UNDO_STORAGE_V = 1 as const
const LAB_UNDO_INTERVAL_MS = 30_000
const LAB_UNDO_MAX_CHECKPOINTS = 10
function labUndoSessionStorageKey(storageId: string): string {
return `jumble:advLabUndo:${storageId}`
}
function cloneLabSlice(s: AdvancedEventLabSlice): AdvancedEventLabSlice {
return { kind: s.kind, content: s.content, tags: s.tags.map((row) => [...row]) }
}
function labSlicesEqual(a: AdvancedEventLabSlice, b: AdvancedEventLabSlice): boolean {
if (a.kind !== b.kind || a.content !== b.content) return false
return JSON.stringify(a.tags) === JSON.stringify(b.tags)
}
function parseCheckpointEntry(raw: unknown): AdvancedEventLabSlice | null {
let json: string
try {
json = JSON.stringify(raw)
} catch {
return null
}
const parsed = parseLabSlice(json)
return parsed.ok ? parsed.value : null
}
function loadLabCheckpointsFromSession(
storageId: string,
base: AdvancedEventLabSlice
): AdvancedEventLabSlice[] | null {
if (!storageId || typeof sessionStorage === 'undefined') return null
try {
const key = labUndoSessionStorageKey(storageId)
const raw = sessionStorage.getItem(key)
if (!raw) return null
const o = JSON.parse(raw) as { v?: number; checkpoints?: unknown }
if (!o || o.v !== LAB_UNDO_STORAGE_V || !Array.isArray(o.checkpoints)) return null
const out: AdvancedEventLabSlice[] = []
for (const row of o.checkpoints) {
const slice = parseCheckpointEntry(row)
if (!slice) return null
out.push(slice)
}
if (out.length === 0) return null
const last = out[out.length - 1]!
if (!labSlicesEqual(last, base)) return null
return out.slice(0, LAB_UNDO_MAX_CHECKPOINTS).map(cloneLabSlice)
} catch {
return null
}
}
function persistLabCheckpointsToSession(storageId: string, checkpoints: AdvancedEventLabSlice[]): void {
if (!storageId || typeof sessionStorage === 'undefined') return
try {
sessionStorage.setItem(
labUndoSessionStorageKey(storageId),
JSON.stringify({ v: LAB_UNDO_STORAGE_V, checkpoints })
)
} catch {
// quota / private mode
}
}
function clearLabCheckpointsSession(storageId: string): void {
if (!storageId || typeof sessionStorage === 'undefined') return
try {
sessionStorage.removeItem(labUndoSessionStorageKey(storageId))
} catch {
/* ignore */
}
}
/** Subset of {@link TPostTextareaHandle} so media upload + toolbar can target the lab surface. */
export type AdvancedLabBodyHandle = {
getText: () => string
insertText: (text: string) => void
appendText: (text: string, addNewline?: boolean) => void
insertEmoji: (emoji: string | TEmoji) => void
}
function cmInsertAtSelection(view: EditorView, text: string) {
const sel = view.state.selection.main
view.dispatch({
changes: { from: sel.from, to: sel.to, insert: text },
selection: EditorSelection.cursor(sel.from + text.length)
})
view.focus()
}
function cmAppendAtEnd(view: EditorView, text: string, addNewline = false) {
const doc = view.state.doc
const at = doc.length
const prefix = addNewline && doc.length > 0 ? '\n' : ''
const insert = prefix + text
view.dispatch({
changes: { from: at, to: at, insert },
selection: EditorSelection.cursor(at + insert.length)
})
view.focus()
}
export type AdvancedEventLabDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Snapshot when opening; parent should memoize. */
initial: AdvancedEventLabSlice | null
/** When false, Apply keeps `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
/** Filled while the markup editor is mounted (for uploads / shared toolbar). */
bodyApiRef?: MutableRefObject<AdvancedLabBodyHandle | null>
/** Same icon row as the main composer; should use {@link bodyApiRef} for inserts. */
formatToolbar?: ReactNode
/**
* When set, lab markup/tags are debounced to the post-editor draft store (same persistence as TipTap)
* so a **reload** can restore in-progress lab work. Closing without Apply (dismiss actions, including the cancel button, Escape, overlay)
* clears this draft so the next open is seeded from TipTap again.
*/
draftPersistenceKey?: string | null
/** Lab preview: resolve custom `:shortcode:` from this author's NIP-30 inventory when tags do not define them. */
previewAuthorPubkey?: string | null
/** Lab preview: `emoji` tags on the fake event (e.g. copied from the event being edited). */
previewEmojiTags?: string[][]
}
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,
bodyApiRef,
formatToolbar,
draftPersistenceKey = null,
previewAuthorPubkey = null,
previewEmojiTags
}: AdvancedEventLabDialogProps) {
const { t, i18n } = useTranslation()
/** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */
const labTRef = useRef(t)
labTRef.current = t
const dark = useDarkModeFlag()
const markupHost = useRef<HTMLDivElement>(null)
const markupView = useRef<EditorView | null>(null)
const sliceRef = useRef<AdvancedEventLabSlice | null>(null)
const draftPersistenceKeyRef = useRef<string | null>(null)
const labUndoAnonIdRef = useRef<string | null>(null)
const labCheckpointsRef = useRef<AdvancedEventLabSlice[]>([])
const [undoUiTick, setUndoUiTick] = useState(0)
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const previewDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const schedulePreviewUpdateRef = useRef<(text: string) => void>(() => {})
/** When true, closing is from Apply (draft already cleared); skip discard cleanup. */
const skipClearLabDraftOnCloseRef = useRef(false)
/** Debounce writes to the draft map; pagehide/beforeunload flush immediately to disk. */
const LAB_DRAFT_DEBOUNCE_MS = 500
const [previewDoc, setPreviewDoc] = useState('')
/** Stable while payload matches; avoids remounting the editor when the parent passes a new `initial` object reference. */
const labEditorMountFingerprint =
initial != null ? `${initial.kind}\0${initial.content}\0${JSON.stringify(initial.tags)}` : ''
const mergedLabPreviewEmojiTags = useMemo(() => {
if (!open || !initial) return []
const fromInitial = initial.tags.filter(([n]) => n === 'emoji').map((r) => [...r])
const fromProp = previewEmojiTags ?? []
const m = new Map<string, string[]>()
for (const row of fromInitial) {
const sc = row[1]?.trim()
if (sc) m.set(sc.toLowerCase(), row)
}
for (const row of fromProp) {
const sc = row[1]?.trim()
if (sc) m.set(sc.toLowerCase(), row)
}
return [...m.values()]
}, [open, initial, previewEmojiTags])
const schedulePreviewUpdate = useCallback((text: string) => {
if (previewDebounceTimerRef.current) {
clearTimeout(previewDebounceTimerRef.current)
}
previewDebounceTimerRef.current = setTimeout(() => {
previewDebounceTimerRef.current = null
setPreviewDoc(text)
}, PREVIEW_DEBOUNCE_MS)
}, [])
useEffect(() => {
schedulePreviewUpdateRef.current = schedulePreviewUpdate
}, [schedulePreviewUpdate])
draftPersistenceKeyRef.current = draftPersistenceKey ?? null
useEffect(() => {
if (!open) labUndoAnonIdRef.current = null
}, [open])
const undoSessionId = useMemo(() => {
if (!open) return ''
if (draftPersistenceKey) return draftPersistenceKey
if (!labUndoAnonIdRef.current) labUndoAnonIdRef.current = crypto.randomUUID()
return labUndoAnonIdRef.current
}, [open, draftPersistenceKey])
const bumpUndoUi = useCallback(() => {
setUndoUiTick((n) => n + 1)
}, [])
/** Writes the live CodeMirror doc into the draft cache. `urgent` flushes localStorage synchronously (tab hide / unload only). */
const flushLabDraftNow = useCallback((key: string, urgent = false) => {
const v = markupView.current
const s = sliceRef.current
if (!v || !s) return
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
labPersistTimerRef.current = null
}
postEditorCache.setAdvancedLabDraft(key, {
kind: s.kind,
content: v.state.doc.toString(),
tags: s.tags.map((row) => [...row])
})
if (urgent) postEditorCache.flushPersist()
}, [])
useEffect(() => {
if (!open || !draftPersistenceKey) return
const key = draftPersistenceKey
const onPageLeave = () => {
flushLabDraftNow(key, true)
}
window.addEventListener('pagehide', onPageLeave)
window.addEventListener('beforeunload', onPageLeave)
const onVisibility = () => {
if (document.visibilityState === 'hidden') onPageLeave()
}
document.addEventListener('visibilitychange', onVisibility)
return () => {
window.removeEventListener('pagehide', onPageLeave)
window.removeEventListener('beforeunload', onPageLeave)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [open, draftPersistenceKey, flushLabDraftNow])
useEffect(() => {
if (!open) {
if (previewDebounceTimerRef.current) {
clearTimeout(previewDebounceTimerRef.current)
previewDebounceTimerRef.current = null
}
setPreviewDoc('')
}
}, [open])
const handleDialogOpenChange = useCallback(
(next: boolean) => {
if (!next) {
if (!skipClearLabDraftOnCloseRef.current) {
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
labPersistTimerRef.current = null
}
const key = draftPersistenceKeyRef.current
if (key) {
postEditorCache.clearAdvancedLabDraft(key)
}
}
skipClearLabDraftOnCloseRef.current = false
}
onOpenChange(next)
},
[onOpenChange]
)
const scheduleLabDraftPersist = useCallback(() => {
const key = draftPersistenceKeyRef.current
if (!key) return
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
}
labPersistTimerRef.current = setTimeout(() => {
labPersistTimerRef.current = null
const v = markupView.current
const s = sliceRef.current
if (!s) return
const content = v?.state.doc.toString() ?? s.content
postEditorCache.setAdvancedLabDraft(key, {
kind: s.kind,
content,
tags: s.tags.map((row) => [...row])
})
}, LAB_DRAFT_DEBOUNCE_MS)
}, [])
const restoreSliceInEditor = useCallback(
(slice: AdvancedEventLabSlice) => {
const v = markupView.current
if (!v) return
v.dispatch({
changes: { from: 0, to: v.state.doc.length, insert: slice.content },
selection: EditorSelection.cursor(0)
})
sliceRef.current = cloneLabSlice(slice)
setPreviewDoc(slice.content)
scheduleLabDraftPersist()
if (isLanguageToolConfigured()) requestAdvancedLabGrammarLint(v)
bumpUndoUi()
},
[bumpUndoUi, scheduleLabDraftPersist]
)
const pushLabCheckpoint = useCallback(() => {
const v = markupView.current
const s = sliceRef.current
if (!v || !s || !undoSessionId) return
const snap = cloneLabSlice({
kind: s.kind,
content: v.state.doc.toString(),
tags: s.tags.map((row) => [...row])
})
const cp = labCheckpointsRef.current
const last = cp[cp.length - 1]
if (last && labSlicesEqual(last, snap)) return
cp.push(snap)
while (cp.length > LAB_UNDO_MAX_CHECKPOINTS) cp.shift()
persistLabCheckpointsToSession(undoSessionId, cp)
bumpUndoUi()
}, [undoSessionId, bumpUndoUi])
const handleUndoCheckpoint = useCallback(() => {
const v = markupView.current
const s = sliceRef.current
if (!v || !s || !undoSessionId) return
const cp = labCheckpointsRef.current
if (cp.length === 0) {
toast.message(t('Advanced lab undo checkpoint none'))
return
}
const live = cloneLabSlice({
kind: s.kind,
content: v.state.doc.toString(),
tags: s.tags.map((row) => [...row])
})
const last = cp[cp.length - 1]!
if (!labSlicesEqual(live, last)) {
restoreSliceInEditor(last)
persistLabCheckpointsToSession(undoSessionId, cp)
toast.success(t('Advanced lab undo checkpoint restored'))
return
}
if (cp.length < 2) {
toast.message(t('Advanced lab undo checkpoint none'))
return
}
cp.pop()
const target = cp[cp.length - 1]!
restoreSliceInEditor(target)
persistLabCheckpointsToSession(undoSessionId, cp)
toast.success(t('Advanced lab undo checkpoint restored'))
}, [undoSessionId, restoreSliceInEditor, t])
const canUndoCheckpoint = useMemo(() => {
if (!open) return false
const v = markupView.current
const s = sliceRef.current
const cp = labCheckpointsRef.current
if (!v || !s || cp.length === 0) return false
const live: AdvancedEventLabSlice = {
kind: s.kind,
content: v.state.doc.toString(),
tags: s.tags.map((row) => [...row])
}
const last = cp[cp.length - 1]!
if (!labSlicesEqual(live, last)) return true
return cp.length >= 2
}, [undoUiTick, open, previewDoc])
useEffect(() => {
if (!open || !initial) return
const pushId = window.setInterval(() => {
pushLabCheckpoint()
}, LAB_UNDO_INTERVAL_MS)
const uiId = window.setInterval(() => {
bumpUndoUi()
}, 2500)
return () => {
clearInterval(pushId)
clearInterval(uiId)
}
}, [open, labEditorMountFingerprint, pushLabCheckpoint, bumpUndoUi])
const [translateLangs, setTranslateLangs] = useState<TranslateLanguageOption[]>([])
const ltList = useMemo(
() => buildLabLanguageToolPreferenceList(i18nLanguage ?? i18n.language, translateLangs),
[i18nLanguage, i18n.language, translateLangs]
)
const [ltLang, setLtLang] = useState(() => ltList[0] ?? 'en-US')
const ltLangRef = useRef(ltLang)
ltLangRef.current = ltLang
const [translateLoad, setTranslateLoad] = useState<'idle' | 'loading' | 'ready' | 'empty' | 'error'>('idle')
const [translateSource, setTranslateSource] = useState('auto')
const [translateTarget, setTranslateTarget] = useState('en')
const [ltLangFilter, setLtLangFilter] = useState('')
const [translateSrcFilter, setTranslateSrcFilter] = useState('')
const [translateTgtFilter, setTranslateTgtFilter] = useState('')
const ltListFiltered = useMemo(() => {
const f = ltList.filter((code) => translateLanguageOptionMatchesQuery(code, ltLangFilter))
return f.length > 0 ? f : ltList
}, [ltList, ltLangFilter])
const translateLangsFilteredSrc = useMemo(() => {
const f = translateLangs.filter((l) => translateLanguageOptionMatchesQuery(l.code, translateSrcFilter))
return f.length > 0 ? f : translateLangs
}, [translateLangs, translateSrcFilter])
const translateLangsFilteredTgt = useMemo(() => {
const f = translateLangs.filter((l) => translateLanguageOptionMatchesQuery(l.code, translateTgtFilter))
return f.length > 0 ? f : translateLangs
}, [translateLangs, translateTgtFilter])
const showTranslateSourceAuto = useMemo(() => {
const q = translateSrcFilter.trim().toLowerCase()
if (!q) return true
if (translateSource === 'auto') return true
const autoLabel = t('Advanced lab translation source auto').toLowerCase()
return q.includes('auto') || q.includes('detect') || autoLabel.includes(q)
}, [translateSrcFilter, translateSource, t])
useEffect(() => {
if (!open) {
setLtLangFilter('')
setTranslateSrcFilter('')
setTranslateTgtFilter('')
}
}, [open])
useEffect(() => {
if (!open) return
setLtLang((prev) => (ltList.includes(prev) ? prev : ltList[0] ?? 'en-US'))
}, [open, ltList])
useEffect(() => {
const v = markupView.current
if (!v || !open || !isLanguageToolConfigured()) return
requestAdvancedLabGrammarLint(v)
}, [ltLang, open])
useEffect(() => {
if (!open || !isTranslateConfigured()) {
setTranslateLangs([])
setTranslateLoad('idle')
return
}
let cancelled = false
setTranslateLoad('loading')
void fetchTranslateLanguages()
.then((list) => {
if (cancelled) return
const resolved = buildResolvedTranslateMenuLanguageOptions(list)
if (!resolved.length) {
setTranslateLangs([])
setTranslateLoad('empty')
return
}
setTranslateLangs([...resolved])
setTranslateSource('auto')
const codes = resolved.map((l: TranslateLanguageOption) => l.code)
const tgt = codes.includes('en') ? 'en' : codes[0]!
setTranslateTarget(tgt)
setTranslateLoad('ready')
})
.catch(() => {
if (cancelled) return
const resolved = buildResolvedTranslateMenuLanguageOptions([])
setTranslateLangs([...resolved])
setTranslateSource('auto')
const codes = resolved.map((l: TranslateLanguageOption) => l.code)
setTranslateTarget(codes.includes('en') ? 'en' : codes[0]!)
setTranslateLoad('ready')
})
return () => {
cancelled = true
}
}, [open])
const destroyEditors = useCallback(() => {
if (bodyApiRef) bodyApiRef.current = null
markupView.current?.destroy()
markupView.current = null
}, [bodyApiRef])
useLayoutEffect(() => {
if (!open || !initial) {
destroyEditors()
return
}
let cancelled = false
let rafId = 0
let attempts = 0
const MAX_RAF_ATTEMPTS = 120
const mountEditors = () => {
if (cancelled) return
const mkEl = markupHost.current
if (!mkEl) {
attempts += 1
if (attempts < MAX_RAF_ATTEMPTS) {
rafId = requestAnimationFrame(mountEditors)
}
return
}
destroyEditors()
const baseSlice: AdvancedEventLabSlice = {
kind: initial.kind,
content: initial.content,
tags: initial.tags.map((row) => [...row])
}
sliceRef.current = baseSlice
setPreviewDoc(baseSlice.content)
const markupLang: Extension =
markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown()
const mkExtensions: Extension[] = [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
cmPlaceholder(
labTRef.current(
markupMode === 'asciidoc'
? 'Advanced lab markup placeholder asciidoc'
: 'Advanced lab markup placeholder markdown'
)
),
markupLang,
EditorView.theme({
'&': { height: '100%', maxHeight: '100%', minHeight: 0 },
'.cm-scroller': { overflow: 'auto', minHeight: 0 },
// Large dvh mins fight stacked flex/grid rows and overflow onto the preview; host + row cap height instead.
'.cm-content': {
minHeight: '11rem',
fontFamily: 'var(--font-mono, ui-monospace, monospace)'
}
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const content = update.state.doc.toString()
const s = sliceRef.current
if (!s) return
s.content = content
schedulePreviewUpdateRef.current(content)
scheduleLabDraftPersist()
})
]
if (isLanguageToolConfigured()) {
mkExtensions.push(
languageToolLintExtension(() => ltLangRef.current, 650, () => markupMode)
)
}
if (dark) mkExtensions.push(oneDark)
const mkState = EditorState.create({
doc: baseSlice.content,
extensions: mkExtensions
})
markupView.current = new EditorView({ state: mkState, parent: mkEl })
const loaded =
undoSessionId && undoSessionId.length > 0
? loadLabCheckpointsFromSession(undoSessionId, baseSlice)
: null
labCheckpointsRef.current =
loaded && loaded.length > 0 ? loaded : [cloneLabSlice(baseSlice)]
if (undoSessionId) {
persistLabCheckpointsToSession(undoSessionId, labCheckpointsRef.current)
}
bumpUndoUi()
if (bodyApiRef) {
bodyApiRef.current = {
getText: () => markupView.current?.state.doc.toString() ?? '',
insertText: (text: string) => {
const v = markupView.current
if (!v) return
cmInsertAtSelection(v, text)
const s = sliceRef.current
if (s) s.content = v.state.doc.toString()
},
appendText: (raw: string, addNewline = false) => {
const v = markupView.current
if (!v) return
cmAppendAtEnd(v, raw, addNewline)
const s = sliceRef.current
if (s) s.content = v.state.doc.toString()
},
insertEmoji: (emoji: string | TEmoji) => {
const v = markupView.current
if (!v) return
let piece: string
if (typeof emoji === 'string') {
piece = emoji
} else {
const sc = emoji.shortcode?.trim()
if (sc) {
piece = `:${sc}: `
} else {
const id = customEmojiService.getEmojiId(emoji)
const ce = customEmojiService.getEmojiById(id)
piece = ce ? `:${ce.shortcode}: ` : `:${id}: `
}
}
cmInsertAtSelection(v, piece)
const s = sliceRef.current
if (s) s.content = v.state.doc.toString()
}
}
}
}
mountEditors()
return () => {
cancelled = true
cancelAnimationFrame(rafId)
if (previewDebounceTimerRef.current) {
clearTimeout(previewDebounceTimerRef.current)
previewDebounceTimerRef.current = null
}
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
labPersistTimerRef.current = null
}
const key = draftPersistenceKeyRef.current
if (key) {
flushLabDraftNow(key)
}
destroyEditors()
}
}, [
open,
labEditorMountFingerprint,
markupMode,
dark,
destroyEditors,
bodyApiRef,
scheduleLabDraftPersist,
flushLabDraftNow,
undoSessionId,
bumpUndoUi
])
const handleApply = () => {
const s = sliceRef.current
if (!s) {
toast.error(t('Advanced lab applyError'))
return
}
const content = markupView.current?.state.doc.toString() ?? s.content
const kind = kindEditable ? s.kind : (initial?.kind ?? s.kind)
const payload: AdvancedEventLabSlice = {
kind,
content,
tags: s.tags.map((row) => [...row])
}
skipClearLabDraftOnCloseRef.current = true
onApply(payload)
if (draftPersistenceKeyRef.current) {
postEditorCache.clearAdvancedLabDraft(draftPersistenceKeyRef.current)
}
if (undoSessionId) {
clearLabCheckpointsSession(undoSessionId)
labCheckpointsRef.current = []
}
handleDialogOpenChange(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
if (translateLoad !== 'ready' || translateLangs.length === 0) return
if (
translateSource !== 'auto' &&
translateApiLanguageCode(translateSource) === translateApiLanguageCode(translateTarget)
) {
toast.message(t('Advanced lab translation same source target'))
return
}
logger.info('[AdvancedLab] translate button', {
source: translateSource,
target: translateTarget,
inputChars: text.length
})
try {
const out = await translateAdvancedLabMarkup(text, translateTarget, translateSource, markupMode)
if (!markupView.current) return
markupView.current.dispatch({
changes: { from: 0, to: markupView.current.state.doc.length, insert: out }
})
const s = sliceRef.current
if (s) s.content = out
if (isLanguageToolConfigured()) {
const nextLt = pickLanguageToolCodeForTranslateTarget(translateTarget, ltList)
if (nextLt !== ltLang) {
logger.info('[AdvancedLab] grammar language synced after translate', {
from: ltLang,
to: nextLt,
translateTarget
})
setLtLang(nextLt)
}
}
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={handleDialogOpenChange}>
<DialogContent
overlayClassName="z-[205]"
className={cnDialogShell()}
aria-describedby={undefined}
>
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b">
<DialogTitle>{t('Advanced event lab')}</DialogTitle>
</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={(code) => {
logger.info('[AdvancedLab] grammar language changed', { from: ltLang, to: code })
setLtLang(code)
}}
>
<SelectTrigger id="lt-lang" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent
className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={ltLangFilter}
onChange={(e) => setLtLangFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="py-1">
{ltListFiltered.map((code) => (
<SelectItem
key={code}
value={code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
) : null}
{isTranslateConfigured() ? (
<div className="flex flex-col gap-2 min-w-0">
{translateLoad === 'idle' || translateLoad === 'loading' ? (
<p className="text-xs text-muted-foreground">{t('Advanced lab translation languages loading')}</p>
) : null}
{translateLoad === 'ready' ? (
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1 min-w-[10rem]">
<Label htmlFor="tr-src">{t('Advanced lab translation source')}</Label>
<Select
value={translateSource}
onValueChange={(v) => {
logger.info('[AdvancedLab] translation source language changed', {
from: translateSource,
to: v
})
setTranslateSource(v)
if (v !== 'auto' && v === translateTarget) {
const alt = translateLangs.find((l) => l.code !== v)?.code
if (alt) setTranslateTarget(alt)
}
}}
>
<SelectTrigger id="tr-src" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent
className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={translateSrcFilter}
onChange={(e) => setTranslateSrcFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="py-1">
{showTranslateSourceAuto ? (
<SelectItem value="auto">{t('Advanced lab translation source auto')}</SelectItem>
) : null}
{translateLangsFilteredSrc.map((l) => (
<SelectItem
key={l.code}
value={l.code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={l.code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<div className="space-y-1 min-w-[10rem]">
<Label htmlFor="tr-tgt">{t('Advanced lab translation target')}</Label>
<Select
value={translateTarget}
onValueChange={(v) => {
logger.info('[AdvancedLab] translation target language changed', {
from: translateTarget,
to: v
})
setTranslateTarget(v)
if (translateSource !== 'auto' && v === translateSource) {
setTranslateSource('auto')
}
}}
>
<SelectTrigger id="tr-tgt" className="min-w-[220px] max-w-md w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent
className="max-h-64 min-w-[var(--radix-select-trigger-width)] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div
className="sticky top-0 z-10 border-b border-border bg-popover p-2"
onPointerDown={(e) => e.stopPropagation()}
>
<Input
type="search"
value={translateTgtFilter}
onChange={(e) => setTranslateTgtFilter(e.target.value)}
placeholder={t('Language list filter placeholder')}
className="h-8"
aria-label={t('Language list filter placeholder')}
/>
</div>
<div className="py-1">
{translateLangsFilteredTgt.map((l) => (
<SelectItem
key={l.code}
value={l.code}
className="items-start py-2.5 whitespace-normal"
>
<LanguageSelectOptionLines tag={l.code} className="w-full" />
</SelectItem>
))}
</div>
</SelectContent>
</Select>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void handleTranslate()}>
{t('Advanced lab translate')}
</Button>
</div>
) : null}
{translateLoad === 'empty' ? (
<p className="text-xs text-destructive">{t('Advanced lab translation languages empty')}</p>
) : null}
{translateLoad === 'error' ? (
<p className="text-xs text-destructive">{t('Advanced lab translation languages error')}</p>
) : null}
</div>
) : null}
{contextEventId && isTranslateConfigured() ? (
<Button type="button" variant="outline" size="sm" onClick={handleReadAloudBuffer}>
{t('Advanced lab use translation read aloud')}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={!canUndoCheckpoint}
title={t('Advanced lab undo checkpoint hint')}
onClick={handleUndoCheckpoint}
>
<Undo2 className="h-4 w-4 mr-1 inline" />
{t('Advanced lab undo checkpoint')}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain">
<AdvancedEventLabMarkupToolbar markupMode={markupMode} viewRef={markupView} sliceRef={sliceRef} />
<div className="flex min-h-0 flex-1 flex-col gap-0 px-4 py-2 max-lg:grid max-lg:grid-rows-[minmax(0,1fr)_minmax(0,1fr)] lg:flex lg:flex-row lg:py-2">
<div className="flex min-h-0 min-w-0 flex-col gap-2 overflow-hidden max-lg:min-h-0 lg:flex-1 lg:pr-3">
<h3 className="shrink-0 text-left text-sm font-semibold leading-none text-foreground">
{t(
markupMode === 'asciidoc'
? 'Advanced lab markup label asciidoc'
: 'Advanced lab markup label markdown'
)}
</h3>
<div
ref={markupHost}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border bg-muted/20 lg:min-h-[min(42dvh,24rem)]"
/>
</div>
<div className="flex min-h-0 min-w-0 flex-col gap-2 overflow-hidden -mx-4 border-t-2 border-border bg-muted/40 px-4 pb-3 pt-4 max-lg:min-h-0 max-lg:rounded-b-lg lg:mx-0 lg:mt-0 lg:flex-[0_1_42%] lg:max-w-[min(50%,40rem)] lg:rounded-none lg:border-t-0 lg:border-l lg:border-border lg:bg-transparent lg:px-0 lg:pb-0 lg:pt-0 lg:pl-3">
<h3 className="shrink-0 text-left text-sm font-semibold leading-none text-foreground">
{t('Advanced lab preview')}
</h3>
<div className="flex min-h-0 flex-1 overflow-y-auto rounded-md border border-border bg-background py-2 pl-0 pr-0 text-left lg:bg-muted/10 lg:px-2">
<AdvancedEventLabPreviewPane
markupMode={markupMode}
source={previewDoc}
previewAuthorPubkey={previewAuthorPubkey}
previewEmojiTags={mergedLabPreviewEmojiTags}
/>
</div>
</div>
</div>
</div>
{formatToolbar ? (
<div className="shrink-0 border-t bg-muted/20 px-2 py-2">{formatToolbar}</div>
) : null}
<DialogFooter className="shrink-0 px-4 py-3 border-t gap-2">
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
{t('Advanced lab cancel undo')}
</Button>
<Button type="button" onClick={handleApply}>
{t('Apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** Responsive shell: ~5× prior max width cap and ~3× vertical use of viewport (still clamped). */
function cnDialogShell(): string {
return [
'z-[250] max-w-none flex min-h-0 flex-col gap-0 overflow-hidden p-0',
'w-[min(98vw,calc(72rem*5))]',
'h-[min(94vh,calc(28rem*3))]',
'max-h-[min(96vh,90dvh)]'
].join(' ')
}