Browse Source

fix editor

imwald
Silberengel 2 weeks ago
parent
commit
065fb15d84
  1. 482
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 753
      src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx
  3. 66
      src/components/AdvancedEventLab/markup-insert.ts
  4. 21
      src/components/EmojiPicker/index.tsx
  5. 186
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 17
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  7. 329
      src/components/PostEditor/PostContent.tsx
  8. 122
      src/components/PostEditor/PostEditorFormatToolbar.tsx
  9. 2
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  10. 96
      src/components/PostEditor/PostTextarea/index.tsx
  11. 4
      src/components/ui/dropdown-menu.tsx
  12. 2
      src/components/ui/popover.tsx
  13. 2
      src/components/ui/select.tsx
  14. 100
      src/i18n/locales/de.ts
  15. 99
      src/i18n/locales/en.ts
  16. 49
      src/services/post-editor-cache.service.ts

482
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -19,19 +19,14 @@ import { @@ -19,19 +19,14 @@ import {
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 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 { EditorSelection, EditorState, type Extension } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import {
EditorView,
@ -39,16 +34,50 @@ import { @@ -39,16 +34,50 @@ import {
lineNumbers,
placeholder as cmPlaceholder
} from '@codemirror/view'
import { useCallback, useEffect, useMemo, useRef, useState } from '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 customEmojiService from '@/services/custom-emoji.service'
import postEditorCache from '@/services/post-editor-cache.service'
import type { TEmoji } from '@/types'
/** 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, `kind` in JSON is shown but Apply forces `initial.kind`. */
/** When false, Apply keeps `initial.kind`. */
kindEditable?: boolean
markupMode: 'markdown' | 'asciidoc'
/** `i18n.language` for LanguageTool default ordering. */
@ -56,6 +85,16 @@ export type AdvancedEventLabDialogProps = { @@ -56,6 +85,16 @@ export type AdvancedEventLabDialogProps = {
/** 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
}
function useDarkModeFlag(): boolean {
@ -83,23 +122,105 @@ export default function AdvancedEventLabDialog({ @@ -83,23 +122,105 @@ export default function AdvancedEventLabDialog({
markupMode,
i18nLanguage,
contextEventId,
onApply
onApply,
bodyApiRef,
formatToolbar,
draftPersistenceKey = null
}: 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 draftPersistenceKeyRef = useRef<string | null>(null)
const labPersistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 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
draftPersistenceKeyRef.current = draftPersistenceKey ?? null
const flushLabDraftNow = useCallback((key: string) => {
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])
})
postEditorCache.flushPersist()
}, [])
useEffect(() => {
if (!open || !draftPersistenceKey) return
const key = draftPersistenceKey
const onPageLeave = () => {
flushLabDraftNow(key)
}
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])
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 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(() => {
@ -109,153 +230,174 @@ export default function AdvancedEventLabDialog({ @@ -109,153 +230,174 @@ export default function AdvancedEventLabDialog({
}, [open, ltList])
const destroyEditors = useCallback(() => {
if (bodyApiRef) bodyApiRef.current = null
markupView.current?.destroy()
jsonView.current?.destroy()
markupView.current = null
jsonView.current = null
}, [])
}, [bodyApiRef])
useEffect(() => {
useLayoutEffect(() => {
if (!open || !initial) {
destroyEditors()
return
}
const mkEl = markupHost.current
const jsEl = jsonHost.current
if (!mkEl || !jsEl) return
destroyEditors()
let cancelled = false
let rafId = 0
let attempts = 0
const MAX_RAF_ATTEMPTS = 120
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
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
const markupLang: Extension =
markupMode === 'asciidoc' ? StreamLanguage.define(asciidoc) : markdown()
const mkExtensions: Extension[] = [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
lineNumbers(),
cmPlaceholder(
t(
markupMode === 'asciidoc'
? 'Advanced lab markup placeholder asciidoc'
: 'Advanced lab markup placeholder markdown'
)
),
markupLang,
EditorView.theme({
'&': { maxHeight: '100%' },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': {
minHeight: 'min(50dvh, 42rem)',
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
scheduleLabDraftPersist()
})
]
if (isLanguageToolConfigured()) {
mkExtensions.push(languageToolLintExtension(ltLang, 450))
}
if (dark) mkExtensions.push(oneDark)
const mkState = EditorState.create({
doc: baseSlice.content,
extensions: mkExtensions
})
]
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 })
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()
}
}
}
}
markupView.current = new EditorView({ state: mkState, parent: mkEl })
jsonView.current = new EditorView({ state: jsState, parent: jsEl })
mountEditors()
return destroyEditors
return () => {
cancelled = true
cancelAnimationFrame(rafId)
if (labPersistTimerRef.current) {
clearTimeout(labPersistTimerRef.current)
labPersistTimerRef.current = null
}
const key = draftPersistenceKeyRef.current
if (key) {
flushLabDraftNow(key)
}
destroyEditors()
}
}, [
open,
initial,
markupMode,
ltLang,
kindEditable,
dark,
destroyEditors,
t
t,
bodyApiRef,
scheduleLabDraftPersist,
flushLabDraftNow
])
const handleApply = () => {
const raw = jsonView.current?.state.doc.toString() ?? ''
const parsed = parseLabSlice(raw)
if (!parsed.ok) {
toast.error(parsed.error)
const s = sliceRef.current
if (!s) {
toast.error(t('Advanced lab applyError'))
return
}
const kind = kindEditable ? parsed.value.kind : (initial?.kind ?? parsed.value.kind)
const content = markupView.current?.state.doc.toString() ?? s.content
const kind = kindEditable ? s.kind : (initial?.kind ?? s.kind)
const payload: AdvancedEventLabSlice = {
kind,
content: parsed.value.content,
tags: parsed.value.tags
content,
tags: s.tags.map((row) => [...row])
}
skipClearLabDraftOnCloseRef.current = true
onApply(payload)
onOpenChange(false)
if (draftPersistenceKeyRef.current) {
postEditorCache.clearAdvancedLabDraft(draftPersistenceKeyRef.current)
}
handleDialogOpenChange(false)
}
const handleTranslate = async () => {
@ -268,26 +410,11 @@ export default function AdvancedEventLabDialog({ @@ -268,26 +410,11 @@ export default function AdvancedEventLabDialog({
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
}
}
if (s) s.content = out
toast.success(t('Advanced lab translate done'))
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
@ -303,8 +430,11 @@ export default function AdvancedEventLabDialog({ @@ -303,8 +430,11 @@ export default function AdvancedEventLabDialog({
}
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">
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
overlayClassName="z-[205]"
className={cnDialogShell()}
>
<DialogHeader className="shrink-0 px-4 pt-4 pb-2 pr-12 border-b">
<DialogTitle>{t('Advanced event lab')}</DialogTitle>
<DialogDescription className="text-left">
@ -354,35 +484,33 @@ export default function AdvancedEventLabDialog({ @@ -354,35 +484,33 @@ export default function AdvancedEventLabDialog({
</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>
<AdvancedEventLabMarkupToolbar markupMode={markupMode} viewRef={markupView} sliceRef={sliceRef} />
<div className="flex-1 min-h-0 flex flex-col gap-1 px-4 py-2 overflow-hidden">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t(
markupMode === 'asciidoc'
? 'Advanced lab markup label asciidoc'
: 'Advanced lab markup label markdown'
)}
</span>
<div
ref={markupHost}
className="flex-1 min-h-[min(50dvh,36rem)] border rounded-md overflow-hidden bg-muted/20"
/>
</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={() => onOpenChange(false)}>
{t('Cancel')}
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
{t('Advanced lab cancel undo')}
</Button>
<Button type="button" onClick={handleApply} disabled={Boolean(jsonError)}>
<Button type="button" onClick={handleApply}>
{t('Apply')}
</Button>
</DialogFooter>
@ -390,3 +518,13 @@ export default function AdvancedEventLabDialog({ @@ -390,3 +518,13 @@ export default function AdvancedEventLabDialog({
</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 flex-col gap-0 p-0 overflow-hidden',
'w-[min(98vw,calc(72rem*5))]',
'h-[min(94vh,calc(28rem*3))]',
'max-h-[min(96vh,90dvh)]'
].join(' ')
}

753
src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx

@ -0,0 +1,753 @@ @@ -0,0 +1,753 @@
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { EditorView } from '@codemirror/view'
import {
Braces,
ChevronDown,
Code2,
Heading,
Image as ImageIcon,
Link2,
List,
ListOrdered,
Minus,
Pilcrow,
Quote,
Sigma,
Table2,
Type,
ListTodo
} from 'lucide-react'
import type { MutableRefObject } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { labInsertRaw, labInsertSnippet, labWrapOrSnippet } from './markup-insert'
/** Languages for fenced / source blocks (labels are English; widely recognized by highlighters). */
const CODE_LANGUAGES = [
'text',
'markdown',
'asciidoc',
'javascript',
'typescript',
'tsx',
'jsx',
'json',
'yaml',
'toml',
'html',
'xml',
'css',
'scss',
'sass',
'sql',
'python',
'rust',
'go',
'c',
'cpp',
'csharp',
'java',
'kotlin',
'swift',
'ruby',
'php',
'bash',
'shell',
'powershell',
'dockerfile',
'graphql',
'http',
'diff',
'ini',
'makefile',
'lua',
'r',
'matlab',
'latex',
'haskell',
'elixir',
'scala',
'zig',
'nim',
'wasm',
'protobuf'
] as const
export type AdvancedEventLabMarkupToolbarProps = {
markupMode: 'markdown' | 'asciidoc'
viewRef: MutableRefObject<EditorView | null>
sliceRef: MutableRefObject<AdvancedEventLabSlice | null>
}
export function AdvancedEventLabMarkupToolbar({
markupMode,
viewRef,
sliceRef
}: AdvancedEventLabMarkupToolbarProps) {
const { t } = useTranslation()
const [codeFilter, setCodeFilter] = useState('')
const [langFilter, setLangFilter] = useState('')
const filteredLangs = useMemo(() => {
const q = codeFilter.trim().toLowerCase()
if (!q) return [...CODE_LANGUAGES]
return CODE_LANGUAGES.filter((id) => id.toLowerCase().includes(q))
}, [codeFilter])
const run = (fn: (v: EditorView) => void) => {
const v = viewRef.current
if (!v) return
fn(v)
}
const mdFence = (lang: string) => {
run((v) =>
labInsertSnippet(v, sliceRef, `\`\`\`${lang}\n`, 'your code here', `\n\`\`\`\n`)
)
}
const adocSource = (lang: string) => {
run((v) =>
labInsertSnippet(
v,
sliceRef,
`[source,${lang}]\n----\n`,
'your code here',
`\n----\n`
)
)
}
if (markupMode === 'markdown') {
return (
<div className="flex flex-wrap items-center gap-1.5 min-w-0 overflow-x-auto border-b bg-muted/30 px-2 py-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0 mr-1">
{t('Advanced lab tb markup tools')}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Heading className="h-3.5 w-3.5" />
{t('Advanced lab tb headings')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] max-h-80 overflow-y-auto w-56">
<DropdownMenuLabel>{t('Advanced lab tb headings hint')}</DropdownMenuLabel>
{(
[
'Advanced lab tb h1',
'Advanced lab tb h2',
'Advanced lab tb h3',
'Advanced lab tb h4',
'Advanced lab tb h5',
'Advanced lab tb h6'
] as const
).map((labelKey, i) => {
const n = i + 1
return (
<DropdownMenuItem
key={labelKey}
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
`${n === 1 ? '' : '\n'}${'#'.repeat(n)} ${t('Advanced lab tb heading placeholder')}\n`
)
)
}
>
{t(labelKey)}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}>
{t('Advanced lab tb horizontalRule')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Type className="h-3.5 w-3.5" />
{t('Advanced lab tb inline')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-56">
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '**', 'bold'))}>
{t('Advanced lab tb bold')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}>
{t('Advanced lab tb italic')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}>
{t('Advanced lab tb strike')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '`', 'code'))}>
{t('Advanced lab tb inlineCode')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '[', 'link text', '](https://example.com)')
)
}
>
<Link2 className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb link')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '![', 'alt text', '](https://example.com/image.png)')
)
}
>
<ImageIcon className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb image')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<List className="h-3.5 w-3.5" />
{t('Advanced lab tb lists')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-56">
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n- item one\n- item two\n'))}>
<List className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb bulletList')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}>
<ListOrdered className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb orderedList')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'\n- [x] Checked box\n- [ ] Unchecked box\n'
)
)
}
>
<ListTodo className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb taskItem')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Quote className="h-3.5 w-3.5" />
{t('Advanced lab tb blocks')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-64">
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n> quoted line\n'))}>
<Quote className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb blockquote')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'\n| Col A | Col B |\n| --- | --- |\n| cell | cell |\n'
)
)
}
>
<Table2 className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb pipeTable')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n[^1]\n'))}>
{t('Advanced lab tb footnoteRef')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(v, sliceRef, '\n[^1]: Footnote text goes here.\n')
)
}
>
{t('Advanced lab tb footnoteDef')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={(o) => !o && setCodeFilter('')}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Code2 className="h-3.5 w-3.5" />
{t('Advanced lab tb codeBlock')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] p-2">
<p className="text-xs text-muted-foreground mb-2 px-1">{t('Advanced lab tb codeBlockHint')}</p>
<Input
value={codeFilter}
onChange={(e) => setCodeFilter(e.target.value)}
placeholder={t('Advanced lab tb filterLanguages')}
className="h-8 text-xs mb-2"
/>
<ScrollArea className="h-[min(50vh,18rem)] pr-2">
<div className="flex flex-col gap-0.5">
{filteredLangs.map((lang) => (
<Button
key={lang}
type="button"
variant="ghost"
size="sm"
className="h-8 justify-start font-mono text-xs"
onClick={() => {
mdFence(lang)
setCodeFilter('')
}}
>
{lang}
</Button>
))}
</div>
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Sigma className="h-3.5 w-3.5" />
{t('Advanced lab tb math')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(70vh,28rem)] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb mathIntro')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '$', 'x^2 + y^2 = r^2', '$')
)
}
>
{t('Advanced lab tb mathInline')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '\n$$\n', 'E = mc^2', '\n$$\n')
)
}
>
{t('Advanced lab tb mathDisplay')}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Advanced lab tb mathCommon')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\frac{numerator}{denominator}$'))}
>
{t('Advanced lab tb katexFrac')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\sqrt{x}$'))}>
{t('Advanced lab tb katexSqrt')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\sum_{i=1}^{n} i$'))}
>
{t('Advanced lab tb katexSum')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\int_{a}^{b} f(x)\\,dx$'))}
>
{t('Advanced lab tb katexInt')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$'
)
)
}
>
{t('Advanced lab tb katexMatrix')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'$$\\begin{cases} x & x > 0 \\\\ -x & x \\le 0 \\end{cases}$$'
)
)
}
>
{t('Advanced lab tb katexCases')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Advanced lab tb mathGreek')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\alpha$'))}>
<code className="text-xs mr-2">{'\\alpha'}</code> {t('Advanced lab tb greekAlpha')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\beta$'))}>
<code className="text-xs mr-2">{'\\beta'}</code> {t('Advanced lab tb greekBeta')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\gamma$'))}>
<code className="text-xs mr-2">{'\\gamma'}</code> {t('Advanced lab tb greekGamma')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\delta$'))}>
<code className="text-xs mr-2">{'\\delta'}</code> {t('Advanced lab tb greekDelta')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\pi$'))}>
<code className="text-xs mr-2">{'\\pi'}</code> {t('Advanced lab tb greekPi')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\theta$'))}>
<code className="text-xs mr-2">{'\\theta'}</code> {t('Advanced lab tb greekTheta')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\lambda$'))}>
<code className="text-xs mr-2">{'\\lambda'}</code> {t('Advanced lab tb greekLambda')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\sigma$'))}>
<code className="text-xs mr-2">{'\\sigma'}</code> {t('Advanced lab tb greekSigma')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\omega$'))}>
<code className="text-xs mr-2">{'\\omega'}</code> {t('Advanced lab tb greekOmega')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '$\\infty$'))}>
<code className="text-xs mr-2">{'\\infty'}</code> {t('Advanced lab tb greekInfty')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-xs shrink-0"
title={t('Advanced lab tb hrTitle')}
onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}
>
<Minus className="h-3.5 w-3.5" />
</Button>
</div>
)
}
/* AsciiDoc */
return (
<div className="flex flex-wrap items-center gap-1.5 min-w-0 overflow-x-auto border-b bg-muted/30 px-2 py-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0 mr-1">
{t('Advanced lab tb markup tools')}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Heading className="h-3.5 w-3.5" />
{t('Advanced lab tb adocTitles')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-60">
<DropdownMenuLabel>{t('Advanced lab tb adocTitlesHint')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, `\n= ${t('Advanced lab tb documentTitle')}\n`))}>
{t('Advanced lab tb adocLevel0')}
</DropdownMenuItem>
{(
[
'Advanced lab tb adocSection1',
'Advanced lab tb adocSection2',
'Advanced lab tb adocSection3',
'Advanced lab tb adocSection4',
'Advanced lab tb adocSection5'
] as const
).map((labelKey, i) => {
const n = i + 1
return (
<DropdownMenuItem
key={labelKey}
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
`\n${'='.repeat(n + 1)} ${t('Advanced lab tb sectionTitle')}\n`
)
)
}
>
{t(labelKey)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Type className="h-3.5 w-3.5" />
{t('Advanced lab tb inline')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-56">
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '*', 'bold'))}>
{t('Advanced lab tb adocBold')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}>
{t('Advanced lab tb adocItalic')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '`', 'mono'))}>
{t('Advanced lab tb adocMono')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, 'link:https://example.com[', 'link text', ']')
)
}
>
<Link2 className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb adocLink')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'\nimage::https://example.com/image.png[Alt text, width=640]\n'
)
)
}
>
<ImageIcon className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb adocImage')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<List className="h-3.5 w-3.5" />
{t('Advanced lab tb lists')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-56">
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}>
{t('Advanced lab tb adocUnordered')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n. first step\n. second step\n'))}>
{t('Advanced lab tb adocOrdered')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\nterm:: definition line\n'))}>
{t('Advanced lab tb adocLabeled')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Pilcrow className="h-3.5 w-3.5" />
{t('Advanced lab tb adocBlocks')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-64">
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '\n____\n', 'Quoted paragraph', '\n____\n')
)
}
>
{t('Advanced lab tb adocQuote')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(v, sliceRef, '\n....\n', 'Literal monospace block', '\n....\n')
)
}
>
{t('Advanced lab tb adocLiteral')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(
v,
sliceRef,
'\n[NOTE]\n====\n',
'Note body',
'\n====\n'
)
)
}
>
{t('Advanced lab tb adocNote')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(
v,
sliceRef,
'\n[TIP]\n====\n',
'Tip body',
'\n====\n'
)
)
}
>
{t('Advanced lab tb adocTip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(
v,
sliceRef,
'\n[WARNING]\n====\n',
'Warning body',
'\n====\n'
)
)
}
>
{t('Advanced lab tb adocWarning')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={(o) => !o && setLangFilter('')}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Braces className="h-3.5 w-3.5" />
{t('Advanced lab tb adocSource')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] p-2">
<p className="text-xs text-muted-foreground mb-2 px-1">{t('Advanced lab tb adocSourceHint')}</p>
<Input
value={langFilter}
onChange={(e) => setLangFilter(e.target.value)}
placeholder={t('Advanced lab tb filterLanguages')}
className="h-8 text-xs mb-2"
/>
<ScrollArea className="h-[min(50vh,18rem)] pr-2">
<div className="flex flex-col gap-0.5">
{CODE_LANGUAGES.filter((id) =>
langFilter.trim() ? id.toLowerCase().includes(langFilter.trim().toLowerCase()) : true
).map((lang) => (
<Button
key={lang}
type="button"
variant="ghost"
size="sm"
className="h-8 justify-start font-mono text-xs"
onClick={() => {
adocSource(lang)
setLangFilter('')
}}
>
{lang}
</Button>
))}
</div>
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<Sigma className="h-3.5 w-3.5" />
{t('Advanced lab tb adocStem')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(70vh,28rem)] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb adocStemHint')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))}
>
{t('Advanced lab tb adocStemInline')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\frac{a}{b}', ']'))}
>
{t('Advanced lab tb katexFrac')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sqrt{x}', ']'))}>
{t('Advanced lab tb katexSqrt')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sum_{i=1}^{n} i', ']'))}
>
{t('Advanced lab tb katexSum')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\int_{a}^{b} f(x)\\,dx', ']'))}
>
{t('Advanced lab tb katexInt')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-xs shrink-0"
title={t('Advanced lab tb adocHrTitle')}
onClick={() => run((v) => labInsertRaw(v, sliceRef, "\n'''\n"))}
>
<Minus className="h-3.5 w-3.5" />
</Button>
</div>
)
}

66
src/components/AdvancedEventLab/markup-insert.ts

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
import { EditorSelection } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
export function labSyncSliceFromView(
view: EditorView,
sliceRef: { current: AdvancedEventLabSlice | null }
) {
const s = sliceRef.current
if (s) s.content = view.state.doc.toString()
}
export function labInsertSnippet(
view: EditorView,
sliceRef: { current: AdvancedEventLabSlice | null },
before: string,
placeholder: string,
after: string
) {
const sel = view.state.selection.main
const insert = before + placeholder + after
const innerFrom = sel.from + before.length
const innerTo = innerFrom + placeholder.length
view.dispatch({
changes: { from: sel.from, to: sel.to, insert },
selection: EditorSelection.range(innerFrom, innerTo)
})
view.focus()
labSyncSliceFromView(view, sliceRef)
}
export function labInsertRaw(
view: EditorView,
sliceRef: { current: AdvancedEventLabSlice | null },
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()
labSyncSliceFromView(view, sliceRef)
}
/** If there is a selection, wrap it; otherwise insert snippet with placeholder between delimiters. */
export function labWrapOrSnippet(
view: EditorView,
sliceRef: { current: AdvancedEventLabSlice | null },
wrap: string,
placeholder: string
) {
const sel = view.state.selection.main
const selected = view.state.sliceDoc(sel.from, sel.to)
if (selected.length > 0) {
const insert = `${wrap}${selected}${wrap}`
view.dispatch({
changes: { from: sel.from, to: sel.to, insert },
selection: EditorSelection.cursor(sel.from + insert.length)
})
view.focus()
labSyncSliceFromView(view, sliceRef)
} else {
labInsertSnippet(view, sliceRef, wrap, placeholder, wrap)
}
}

21
src/components/EmojiPicker/index.tsx

@ -64,13 +64,28 @@ export default function EmojiPicker({ @@ -64,13 +64,28 @@ export default function EmojiPicker({
const handleClick = (e: Event) => {
const detail = (e as CustomEvent).detail as {
unicode?: string
emoji: { custom?: boolean; shortcodes?: string[]; url?: string }
emoji: {
custom?: boolean
unicode?: string
name?: string
shortcodes?: string[]
url?: string
}
}
let result: string | TEmoji | undefined
if (detail.unicode) {
result = detail.unicode
} else if (detail.emoji?.custom && detail.emoji.shortcodes?.[0] && detail.emoji.url) {
result = { shortcode: detail.emoji.shortcodes[0], url: detail.emoji.url }
} else {
const em = detail.emoji
// emoji-picker-element: native emojis have `unicode`; custom entries have `url` (+ name / shortcodes).
if (em?.url && !em.unicode) {
const shortcode = em.shortcodes?.[0] ?? em.name
if (shortcode) {
result = { shortcode, url: em.url }
}
} else if (em?.custom && em.shortcodes?.[0] && em.url) {
result = { shortcode: em.shortcodes[0], url: em.url }
}
}
if (result !== undefined) recordEmojiUsed(result)
onEmojiClick(result, e)

186
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -159,6 +159,43 @@ function parseDelimitedMath(value: string): ParsedMathDelimiter { @@ -159,6 +159,43 @@ function parseDelimitedMath(value: string): ParsedMathDelimiter {
return null
}
/**
* Marked often emits **one** paragraph token for several consecutive `$$$$` blocks (only newline between
* closings and openings). {@link parseDelimitedMath} only handles a single span that fills the whole string,
* so split here and render each display block with KaTeX.
*/
function splitParagraphByDisplayMath(
raw: string
): Array<{ kind: 'math'; expression: string } | { kind: 'markdown'; text: string }> | null {
if (!raw.includes('$$')) return null
const out: Array<{ kind: 'math'; expression: string } | { kind: 'markdown'; text: string }> = []
let i = 0
while (i < raw.length) {
const open = raw.indexOf('$$', i)
if (open === -1) {
const rest = raw.slice(i)
if (rest.trim()) out.push({ kind: 'markdown', text: rest })
break
}
if (open > i) {
const before = raw.slice(i, open)
if (before.trim()) out.push({ kind: 'markdown', text: before })
}
const close = raw.indexOf('$$', open + 2)
if (close === -1) {
const tail = raw.slice(open)
if (tail.trim()) out.push({ kind: 'markdown', text: tail })
break
}
const expression = raw.slice(open + 2, close).trim()
if (expression) out.push({ kind: 'math', expression })
i = close + 2
while (i < raw.length && /\s/.test(raw[i]!)) i++
}
if (!out.some((s) => s.kind === 'math')) return null
return out
}
function collectMathInlinePatterns(text: string): Array<{ index: number; end: number; type: 'math-inline' | 'math-block'; data: string }> {
const patterns: Array<{ index: number; end: number; type: 'math-inline' | 'math-block'; data: string }> = []
@ -387,12 +424,36 @@ function isZapStreamUrl(url: string): boolean { @@ -387,12 +424,36 @@ function isZapStreamUrl(url: string): boolean {
return regex.test(url)
}
/** Uppercase label for fenced code blocks in the article preview (e.g. TYPESCRIPT, C#). */
function formatFenceLanguageLabel(lang: string): string {
const raw = lang.trim()
if (!raw) return ''
const id = raw.toLowerCase()
const map: Record<string, string> = {
csharp: 'C#',
cpp: 'C++',
'c++': 'C++',
javascript: 'JAVASCRIPT',
js: 'JAVASCRIPT',
typescript: 'TYPESCRIPT',
ts: 'TYPESCRIPT',
tsx: 'TSX',
jsx: 'JSX',
bash: 'BASH',
shell: 'SHELL',
sh: 'SHELL',
dockerfile: 'DOCKERFILE'
}
return map[id] ?? id.toUpperCase()
}
/**
* CodeBlock component that renders code with syntax highlighting using highlight.js
*/
function CodeBlock({ id, code, language }: { id: string; code: string; language: string }) {
const codeRef = useRef<HTMLDivElement>(null)
const label = formatFenceLanguageLabel(language)
useEffect(() => {
let cancelled = false
const initHighlight = async () => {
@ -419,10 +480,22 @@ function CodeBlock({ id, code, language }: { id: string; code: string; language: @@ -419,10 +480,22 @@ function CodeBlock({ id, code, language }: { id: string; code: string; language:
window.clearTimeout(timeoutId)
}
}, [code, language])
return (
<div className="my-4 overflow-x-auto">
<pre className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg border border-gray-200 dark:border-gray-700 whitespace-pre-wrap">
<div className="my-4 overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
{label ? (
<div
className="rounded-t-lg border-b border-gray-200 dark:border-gray-700 bg-muted/70 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
aria-hidden
>
{label}
</div>
) : null}
<pre
className={`bg-gray-50 dark:bg-gray-900 p-4 whitespace-pre-wrap ${
label ? 'rounded-b-lg' : 'rounded-lg'
}`}
>
<div ref={codeRef}>
<code
id={id}
@ -3205,6 +3278,20 @@ function parseMarkdownContentMarked( @@ -3205,6 +3278,20 @@ function parseMarkdownContentMarked(
<InlineCode key={`${key}-code`} keyPrefix={`${key}-code`} code={String(token.text ?? '')} />
)
break
case 'checkbox': {
const checked = Boolean(token.checked)
out.push(
<span
key={`${key}-chk`}
className="inline-flex h-[1.25em] shrink-0 select-none items-center justify-center text-[1.05rem] leading-none text-foreground"
aria-label={checked ? 'Checked task item' : 'Unchecked task item'}
role="img"
>
{checked ? '\u2611' : '\u2610'}
</span>
)
break
}
case 'link': {
const href = String(token.href ?? '')
const children = stripNestedAnchorsFromNodes(
@ -3310,6 +3397,30 @@ function parseMarkdownContentMarked( @@ -3310,6 +3397,30 @@ function parseMarkdownContentMarked(
const renderParagraph = (token: any, key: string): React.ReactNode => {
const rawParagraphText = String(token.text ?? token.raw ?? '')
const paragraphText = rawParagraphText.trim()
const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText)
if (displayMathSplit) {
return (
<div key={`${key}-display-math-split`} className="space-y-2">
{displayMathSplit.map((seg, idx) =>
seg.kind === 'math' ? (
<MathExpression
key={`${key}-dm-${idx}`}
keyPrefix={`${key}-dm-${idx}`}
expression={seg.expression}
displayMode
/>
) : (
<p key={`${key}-dmt-${idx}`} className="mb-1 last:mb-0">
{renderInlineTokens(
lexInlineProtected(seg.text.trim()),
`${key}-dmt-${idx}`
)}
</p>
)
)}
</div>
)
}
const standaloneMath = parseDelimitedMath(rawParagraphText.trim())
if (standaloneMath) {
return (
@ -4111,12 +4222,32 @@ function parseMarkdownContentMarked( @@ -4111,12 +4222,32 @@ function parseMarkdownContentMarked(
break
}
case 'list': {
const items: any[] = token.items ?? []
const isTaskList = items.some((it: any) => it.task)
const ListTag = token.ordered ? 'ol' : 'ul'
const listClass = token.ordered
? 'list-decimal list-outside my-2 ml-6'
: 'list-disc list-outside my-2 ml-6 space-y-1'
const listClass = isTaskList
? 'my-2 ml-0 list-none space-y-1.5'
: token.ordered
? 'list-decimal list-outside my-2 ml-6'
: 'list-disc list-outside my-2 ml-6 space-y-1'
const startNum = token.ordered ? Number((token as { start?: number }).start ?? 1) : 1
const renderListItemContent = (item: any, itemKey: string): React.ReactNode => {
const itemTokens = item.tokens ?? [{ type: 'text', text: item.text ?? '' }]
if (item.task) {
if (
itemTokens.length === 1 &&
itemTokens[0]?.type === 'paragraph' &&
Array.isArray(itemTokens[0].tokens)
) {
return renderInlineTokens(itemTokens[0].tokens, `${itemKey}-task-p`)
}
if (itemTokens.some((t: any) => t.type === 'checkbox')) {
return renderInlineTokens(itemTokens, `${itemKey}-task-flat`)
}
}
if (itemTokens.length === 1) {
const single = itemTokens[0]
if (single.type === 'text') {
@ -4134,15 +4265,40 @@ function parseMarkdownContentMarked( @@ -4134,15 +4265,40 @@ function parseMarkdownContentMarked(
}
return renderBlockTokens(itemTokens, itemKey)
}
const listBody = React.createElement(
ListTag,
{ className: listClass },
items.map((item: any, itemIdx: number) => (
<li
key={`${key}-li-${itemIdx}`}
className={isTaskList ? 'flex list-none items-center gap-2' : undefined}
>
{isTaskList && token.ordered ? (
<span
className="inline-flex min-w-[1.25rem] shrink-0 items-center justify-end self-center text-right text-xs tabular-nums leading-none text-muted-foreground"
aria-hidden
>
{startNum + itemIdx}.
</span>
) : null}
{isTaskList ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5">
{renderListItemContent(item, `${key}-li-${itemIdx}`)}
</div>
) : (
renderListItemContent(item, `${key}-li-${itemIdx}`)
)}
</li>
))
)
nodes.push(
React.createElement(
ListTag,
{ key: `${key}-list`, className: listClass },
(token.items ?? []).map((item: any, itemIdx: number) => (
<li key={`${key}-li-${itemIdx}`}>
{renderListItemContent(item, `${key}-li-${itemIdx}`)}
</li>
))
isTaskList ? (
<div key={`${key}-tasklist`} className="not-prose max-w-none">
{listBody}
</div>
) : (
React.cloneElement(listBody, { key: `${key}-list` })
)
)
break

17
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -40,6 +40,7 @@ import { @@ -40,6 +40,7 @@ import {
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service'
import postEditorCache from '@/services/post-editor-cache.service'
import type { TDraftEvent } from '@/types'
import dayjs from 'dayjs'
import { AlertTriangle, Code2, Plus, Trash2 } from 'lucide-react'
@ -147,6 +148,19 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -147,6 +148,19 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
[isCreate, createKindInput]
)
/** Stable lab draft bucket (separate from composer {@link postEditorCache.generateCacheKey}). */
const advancedLabDraftPersistenceKey = useMemo(() => {
if (isCreate) {
if (parsedCreateKind === null) return null
return `event-lab:ecc:create:${parsedCreateKind}`
}
const id = sourceEvent!.id.trim()
const normalized = /^[0-9a-f]{64}$/i.test(id) ? id.toLowerCase() : id
return mode === 'edit'
? `event-lab:ecc:edit:${normalized}`
: `event-lab:ecc:clone:${normalized}`
}, [isCreate, parsedCreateKind, sourceEvent, mode])
const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind
useEffect(() => {
@ -587,6 +601,9 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp @@ -587,6 +601,9 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
markupMode={isAsciidocMarkupKind(labKind) ? 'asciidoc' : 'markdown'}
i18nLanguage={i18n.language}
contextEventId={!isCreate && sourceEvent ? sourceEvent.id : null}
draftPersistenceKey={
advancedLabOpen && advancedLabDraftPersistenceKey ? advancedLabDraftPersistenceKey : null
}
onApply={(payload) => {
setContent(payload.content)
setTagRows(payload.tags.length > 0 ? payload.tags.map((r) => [...r]) : [['', '']])

329
src/components/PostEditor/PostContent.tsx

@ -4,7 +4,6 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -4,7 +4,6 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
@ -39,7 +38,7 @@ import { @@ -39,7 +38,7 @@ import {
mergeUploadImetaTagsInto
} from '@/lib/draft-event'
import { ExtendedKind, MAX_PUBLISH_RELAYS } from '@/constants'
import { cn, isTouchDevice } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
@ -54,12 +53,9 @@ import { @@ -54,12 +53,9 @@ import {
Book,
Check,
ChevronDown,
ImageUp,
ListTodo,
MessageCircle,
MessagesSquare,
Settings,
Smile,
Users,
X,
Highlighter,
@ -68,11 +64,8 @@ import { @@ -68,11 +64,8 @@ import {
Quote,
StickyNote,
Upload,
Mic,
Music,
Video,
Film,
Laugh,
Code2
} from 'lucide-react'
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media'
@ -107,9 +100,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -107,9 +100,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import EmojiPickerDialog from '../EmojiPickerDialog'
import GifPicker from '../GifPicker'
import MemePicker from '../MemePicker'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Mentions, { extractMentions } from './Mentions'
import PollEditor from './PollEditor'
@ -117,14 +107,54 @@ import PostOptions from './PostOptions' @@ -117,14 +107,54 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog'
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons'
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog'
import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import AdvancedEventLabDialog, {
type AdvancedLabBodyHandle
} from '@/components/AdvancedEventLab/AdvancedEventLabDialog'
import {
PostEditorFormatToolbar,
type PostEditorFormatToolbarUploadHandlers
} from './PostEditorFormatToolbar'
import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds'
function stripUrlForImageExtensionCheck(url: string): string {
return url.trim().split(/[#?]/)[0].toLowerCase()
}
function imageUrlLooksLikeHttpImage(url: string): boolean {
return /\.(gif|jpe?g|png|webp|avif|bmp|svg)$/i.test(stripUrlForImageExtensionCheck(url))
}
function labInsertShouldBecomeMarkupImage(txt: string): boolean {
const t = txt.trim()
if (!/^https?:\/\//i.test(t)) return false
if (/^\s*!\[/.test(t)) return false
if (/^\s*image::/i.test(t)) return false
if (imageUrlLooksLikeHttpImage(t)) return true
try {
const host = new URL(t).hostname.toLowerCase()
if (host.endsWith('tenor.com') || host.endsWith('giphy.com')) return true
} catch {
/* ignore */
}
return false
}
function formatMarkupImageAppend(url: string, asciidoc: boolean): string {
const safe = url.trim()
if (asciidoc) return `\nimage::${safe}[Image]\n`
return `\n![image](${safe})\n`
}
function formatMarkupImageAtCursor(url: string, asciidoc: boolean): string {
const safe = url.trim()
if (asciidoc) return `image::${safe}[Image]`
return `![image](${safe})`
}
export default function PostContent({
defaultContent = '',
parentEvent,
@ -210,6 +240,15 @@ export default function PostContent({ @@ -210,6 +240,15 @@ export default function PostContent({
const textareaRef = useRef<TPostTextareaHandle>(null)
const labTagOverrideRef = useRef<string[][] | null>(null)
const [advancedLabOpen, setAdvancedLabOpen] = useState(false)
const advancedLabOpenRef = useRef(false)
useEffect(() => {
advancedLabOpenRef.current = advancedLabOpen
}, [advancedLabOpen])
const advancedLabBodyApiRef = useRef<AdvancedLabBodyHandle | null>(null)
const getActiveComposerBody = () =>
advancedLabOpenRef.current && advancedLabBodyApiRef.current
? advancedLabBodyApiRef.current
: textareaRef.current
const [advancedLabInitial, setAdvancedLabInitial] = useState<AdvancedEventLabSlice | null>(null)
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null)
const [posting, setPosting] = useState(false)
@ -628,6 +667,22 @@ export default function PostContent({ @@ -628,6 +667,22 @@ export default function PostContent({
parentEvent
])
const getDeterminedKindRef = useRef(getDeterminedKind)
getDeterminedKindRef.current = getDeterminedKind
const appendUploadedUrlToComposer = (url: string, treatAsImage: boolean) => {
const ed = getActiveComposerBody()
if (!ed || ed.getText().includes(url)) return
if (ed === advancedLabBodyApiRef.current && treatAsImage) {
ed.appendText(
formatMarkupImageAppend(url, isAsciidocMarkupKind(getDeterminedKindRef.current)),
false
)
return
}
ed.appendText(url, true)
}
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
@ -1098,7 +1153,8 @@ export default function PostContent({ @@ -1098,7 +1153,8 @@ export default function PostContent({
try {
// Clean tracking parameters from URLs in the post content
const cleanedText = rewritePlainTextHttpUrls(text)
const body = textareaRef.current?.getText() ?? text
const cleanedText = rewritePlainTextHttpUrls(body)
let draftEvent = await createDraftEvent(cleanedText)
draftEvent = applyLabTagOverrideToDraft(draftEvent)
@ -1108,6 +1164,40 @@ export default function PostContent({ @@ -1108,6 +1164,40 @@ export default function PostContent({
}
}, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag, applyLabTagOverrideToDraft])
const applyComposerDraftJson = useCallback(
(raw: string) => {
const parsed = parseLabSlice(raw.trim())
if (!parsed.ok) {
toast.error(parsed.error)
return false
}
if (parsed.value.kind !== getDeterminedKind) {
toast.error(
t('composerJsonKindMismatch', {
expected: String(getDeterminedKind),
got: String(parsed.value.kind)
})
)
return false
}
labTagOverrideRef.current = parsed.value.tags.map((r) => [...r])
textareaRef.current?.setDocumentFromPlainText(parsed.value.content)
toast.success(t('composerJsonApplySuccess'))
return true
},
[getDeterminedKind, t]
)
const advancedLabPersistenceKey = useMemo(
() =>
postEditorCache.generateCacheKey({
kind: getDeterminedKind,
defaultContent,
parentEvent
}),
[getDeterminedKind, defaultContent, parentEvent]
)
const handleOpenAdvancedLab = useCallback(async () => {
await checkLogin(async () => {
if (!pubkey) {
@ -1115,19 +1205,39 @@ export default function PostContent({ @@ -1115,19 +1205,39 @@ export default function PostContent({
return
}
try {
const cleanedText = rewritePlainTextHttpUrls(text)
const d = await createDraftEvent(cleanedText)
setAdvancedLabInitial({
kind: d.kind,
content: d.content,
tags: (d.tags ?? []).map((row: string[]) => [...row])
})
const body = textareaRef.current?.getText() ?? text
const cleanedText = rewritePlainTextHttpUrls(body)
let d = await createDraftEvent(cleanedText)
d = applyLabTagOverrideToDraft(d)
const labKey = advancedLabPersistenceKey
const saved = postEditorCache.getAdvancedLabDraft(labKey)
if (saved && saved.kind === d.kind) {
setAdvancedLabInitial({
kind: saved.kind,
content: saved.content,
tags: saved.tags.map((row: string[]) => [...row])
})
} else {
setAdvancedLabInitial({
kind: d.kind,
content: d.content,
tags: (d.tags ?? []).map((row: string[]) => [...row])
})
}
setAdvancedLabOpen(true)
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e))
}
})
}, [checkLogin, pubkey, text, createDraftEvent, t])
}, [
checkLogin,
pubkey,
text,
createDraftEvent,
applyLabTagOverrideToDraft,
advancedLabPersistenceKey,
t
])
const post = async (e?: React.MouseEvent) => {
e?.stopPropagation()
@ -1810,13 +1920,12 @@ export default function PostContent({ @@ -1810,13 +1920,12 @@ export default function PostContent({
appendComposerImetaTag(newImetaTag)
if (!opts?.skipComposerUrlAppend) {
const treatAsImage =
resolvedKind === ExtendedKind.PICTURE ||
(uploadingFile.type?.startsWith('image/') ?? false) ||
imageUrlLooksLikeHttpImage(url)
setTimeout(() => {
if (textareaRef.current) {
const currentText = textareaRef.current.getText()
if (!currentText.includes(url)) {
textareaRef.current.appendText(url, true)
}
}
appendUploadedUrlToComposer(url, treatAsImage)
}, 100)
}
} catch (error) {
@ -1867,10 +1976,7 @@ export default function PostContent({ @@ -1867,10 +1976,7 @@ export default function PostContent({
if (isDiscussionThread && !parentEvent) {
if (!urlAlreadyInEditor) {
setTimeout(() => {
const ed = textareaRef.current
if (ed && !ed.getText().includes(url)) {
ed.appendText(url, true)
}
appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url))
}, 100)
}
uploadedMediaFileMap.current.delete(`${uploadingFile.name}-${uploadingFile.size}-${uploadingFile.lastModified}`)
@ -1953,11 +2059,7 @@ export default function PostContent({ @@ -1953,11 +2059,7 @@ export default function PostContent({
// Use setTimeout to ensure the state has updated and editor is ready
if (!urlAlreadyInEditor) {
setTimeout(() => {
const ed = textareaRef.current
if (!ed) return
if (!ed.getText().includes(url)) {
ed.appendText(url, true)
}
appendUploadedUrlToComposer(url, false)
}, 100)
}
} else {
@ -1968,10 +2070,7 @@ export default function PostContent({ @@ -1968,10 +2070,7 @@ export default function PostContent({
setMediaImetaTags([])
composerImetaTagsRef.current = []
if (!urlAlreadyInEditor) {
const ed = textareaRef.current
if (ed && !ed.getText().includes(url)) {
ed.appendText(url, true)
}
appendUploadedUrlToComposer(url, imageUrlLooksLikeHttpImage(url))
}
return // Don't set media note kind for non-audio in replies/PMs
}
@ -2017,6 +2116,25 @@ export default function PostContent({ @@ -2017,6 +2116,25 @@ export default function PostContent({
uploadedMediaFileMap.current.clear()
}
const toolbarUploadHandlers = useMemo<PostEditorFormatToolbarUploadHandlers>(
() => ({
onUploadSuccess: handleMediaUploadSuccess,
onUploadStart: handleUploadStart,
onUploadEnd: handleUploadEnd,
onProgress: handleUploadProgress,
onUploadCompressPhase: handleUploadCompressPhase,
onUploadCompressProgress: handleUploadCompressProgress
}),
[
handleMediaUploadSuccess,
handleUploadStart,
handleUploadEnd,
handleUploadProgress,
handleUploadCompressPhase,
handleUploadCompressProgress
]
)
const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => {
if (parentEvent) return // Can't create articles as replies
@ -2888,6 +3006,8 @@ export default function PostContent({ @@ -2888,6 +3006,8 @@ export default function PostContent({
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
getDraftEventJson={getDraftEventJson}
expectedDraftKind={pubkey ? getDeterminedKind : undefined}
onApplyComposerDraftJson={pubkey ? applyComposerDraftJson : undefined}
extraPreviewTags={
isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags
}
@ -3254,90 +3374,19 @@ export default function PostContent({ @@ -3254,90 +3374,19 @@ export default function PostContent({
)}
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0">
<div className="flex gap-2 items-center min-w-0 shrink-0">
{/* Audio button for replies and new PMs - placed before image button */}
{(parentEvent || isPublicMessage) && (
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="audio/*,.mka,audio/x-matroska"
>
<Button
type="button"
variant="ghost"
size="icon"
title={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
className={mediaNoteKind === ExtendedKind.VOICE_COMMENT || (isPublicMessage && mediaNoteKind === ExtendedKind.VOICE) ? 'bg-accent' : ''}
>
<Mic className="h-4 w-4" />
</Button>
</Uploader>
)}
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
onUploadCompressPhase={handleUploadCompressPhase}
onUploadCompressProgress={handleUploadCompressProgress}
accept="image/*"
>
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" />
{/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}
{!isTouchDevice() && (
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (!emoji) return
textareaRef.current?.insertEmoji(emoji)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}>
<Smile />
</Button>
</EmojiPickerDialog>
)}
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker
onSelect={(memeUrl) => {
textareaRef.current?.insertText(memeUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" />
<MentionAndEventToolbarButtons
insertAtCursor={(text) => textareaRef.current?.insertText(text)}
variant="ghost"
<PostEditorFormatToolbar
insertText={(txt) => textareaRef.current?.insertText(txt)}
insertEmoji={(em) => textareaRef.current?.insertEmoji(em)}
upload={toolbarUploadHandlers}
showAudioUpload={Boolean(parentEvent || isPublicMessage)}
audioUploadTitle={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
audioButtonHighlighted={
mediaNoteKind === ExtendedKind.VOICE_COMMENT ||
(isPublicMessage && mediaNoteKind === ExtendedKind.VOICE)
}
showMoreOptions={showMoreOptions}
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/>
<Button
type="button"
variant="ghost"
size="icon"
title={t('More options')}
className={showMoreOptions ? 'bg-accent' : ''}
onClick={() => setShowMoreOptions((pre) => !pre)}
>
<Settings />
</Button>
</div>
<div className="flex gap-2 items-center shrink-0">
<Mentions
@ -3546,6 +3595,36 @@ export default function PostContent({ @@ -3546,6 +3595,36 @@ export default function PostContent({
markupMode={isAsciidocMarkupKind(getDeterminedKind) ? 'asciidoc' : 'markdown'}
i18nLanguage={i18n.language}
contextEventId={parentEvent?.id ?? null}
draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null}
bodyApiRef={advancedLabBodyApiRef}
formatToolbar={
<PostEditorFormatToolbar
insertText={(txt) => {
const lab = advancedLabBodyApiRef.current
if (!lab) return
if (labInsertShouldBecomeMarkupImage(txt)) {
lab.insertText(
formatMarkupImageAtCursor(
txt,
isAsciidocMarkupKind(getDeterminedKindRef.current)
)
)
} else {
lab.insertText(txt)
}
}}
insertEmoji={(em) => advancedLabBodyApiRef.current?.insertEmoji(em)}
upload={toolbarUploadHandlers}
showAudioUpload={Boolean(parentEvent || isPublicMessage)}
audioUploadTitle={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')}
audioButtonHighlighted={
mediaNoteKind === ExtendedKind.VOICE_COMMENT ||
(isPublicMessage && mediaNoteKind === ExtendedKind.VOICE)
}
showMoreOptions={showMoreOptions}
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/>
}
onApply={(payload) => {
labTagOverrideRef.current = payload.tags.map((r) => [...r])
textareaRef.current?.setDocumentFromPlainText(payload.content)

122
src/components/PostEditor/PostEditorFormatToolbar.tsx

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import GifPicker from '@/components/GifPicker'
import MemePicker from '@/components/MemePicker'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isTouchDevice } from '@/lib/utils'
import type { TEmoji } from '@/types'
import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import Uploader from './Uploader'
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons'
export type PostEditorFormatToolbarUploadHandlers = {
onUploadSuccess: (result: { url: string; tags: string[][]; file?: File }) => void
onUploadStart?: (file: File, cancel: () => void) => void
onUploadEnd?: (file: File) => void
onProgress?: (file: File, progress: number) => void
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void
onUploadCompressProgress?: (file: File, percent: number) => void
}
export type PostEditorFormatToolbarProps = {
insertText: (text: string) => void
insertEmoji: (emoji: string | TEmoji) => void
upload: PostEditorFormatToolbarUploadHandlers
showAudioUpload: boolean
audioUploadTitle: string
audioButtonHighlighted: boolean
showMoreOptions: boolean
onToggleMoreOptions: () => void
}
/**
* Icon row under the composer: media upload, emoji/GIF/meme, npub + nevent/naddr, more options.
* Must render inside {@link NeventPickerProvider} when using mention/event buttons.
*/
export function PostEditorFormatToolbar({
insertText,
insertEmoji,
upload,
showAudioUpload,
audioUploadTitle,
audioButtonHighlighted,
showMoreOptions,
onToggleMoreOptions
}: PostEditorFormatToolbarProps) {
const { t } = useTranslation()
return (
<div className="flex flex-wrap items-center gap-2 min-w-0 shrink-0">
{showAudioUpload && (
<Uploader
onUploadSuccess={upload.onUploadSuccess}
onUploadStart={upload.onUploadStart}
onUploadEnd={upload.onUploadEnd}
onProgress={upload.onProgress}
onUploadCompressPhase={upload.onUploadCompressPhase}
onUploadCompressProgress={upload.onUploadCompressProgress}
accept="audio/*,.mka,audio/x-matroska"
>
<Button
type="button"
variant="ghost"
size="icon"
title={audioUploadTitle}
className={audioButtonHighlighted ? 'bg-accent' : ''}
>
<Mic className="h-4 w-4" />
</Button>
</Uploader>
)}
<Uploader
onUploadSuccess={upload.onUploadSuccess}
onUploadStart={upload.onUploadStart}
onUploadEnd={upload.onUploadEnd}
onProgress={upload.onProgress}
onUploadCompressPhase={upload.onUploadCompressPhase}
onUploadCompressProgress={upload.onUploadCompressProgress}
accept="image/*"
>
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" />
{!isTouchDevice() && (
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (emoji == null) return
insertEmoji(emoji)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}>
<Smile />
</Button>
</EmojiPickerDialog>
)}
<GifPicker onSelect={(gifUrl) => insertText(gifUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker onSelect={(memeUrl) => insertText(memeUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" />
<MentionAndEventToolbarButtons insertAtCursor={insertText} variant="ghost" />
<Button
type="button"
variant="ghost"
size="icon"
title={t('More options')}
className={showMoreOptions ? 'bg-accent' : ''}
onClick={onToggleMoreOptions}
>
<Settings />
</Button>
</div>
)
}

2
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -103,7 +103,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -103,7 +103,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
<div
className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col max-h-80 min-h-0 overflow-y-scroll overflow-x-hidden',
inDialog ? 'z-[210]' : 'z-[110]'
inDialog ? 'z-[290]' : 'z-[110]'
)}
onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}

96
src/components/PostEditor/PostTextarea/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { parseEditorJsonToText, plainTextToTipTapDoc } from '@/lib/tiptap'
import { cn } from '@/lib/utils'
@ -32,6 +33,7 @@ import mentionSuggestion from './Mention/suggestion' @@ -32,6 +33,7 @@ import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview'
import { HighlightData } from '../HighlightEditor'
import { getKindDescription } from '@/lib/kind-description'
import { parseLabSlice } from '@/lib/advanced-event-lab-slice'
/** Draft JSON uses relay fetches (e.g. thread root); cap wait so the Json tab cannot spin forever. */
const DRAFT_JSON_PREVIEW_TIMEOUT_MS = 25_000
@ -71,6 +73,9 @@ const PostTextarea = forwardRef< @@ -71,6 +73,9 @@ const PostTextarea = forwardRef<
pollCreateData?: import('@/types').TPollCreateData
headerActions?: React.ReactNode
getDraftEventJson?: () => Promise<string>
/** When set with `onApplyComposerDraftJson`, the Json tab becomes editable with Apply. */
expectedDraftKind?: number
onApplyComposerDraftJson?: (rawJson: string) => boolean
mediaImetaTags?: string[][]
mediaUrl?: string
articleMetadata?: {
@ -103,6 +108,8 @@ const PostTextarea = forwardRef< @@ -103,6 +108,8 @@ const PostTextarea = forwardRef<
pollCreateData,
headerActions,
getDraftEventJson,
expectedDraftKind,
onApplyComposerDraftJson,
mediaImetaTags,
mediaUrl,
articleMetadata,
@ -121,6 +128,8 @@ const PostTextarea = forwardRef< @@ -121,6 +128,8 @@ const PostTextarea = forwardRef<
const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false)
const [jsonFieldValue, setJsonFieldValue] = useState('')
const [jsonReloadToken, setJsonReloadToken] = useState(0)
/** Bumps when preview tab is shown or a new JSON fetch starts; completions only apply if seq still matches. */
const jsonPanelFetchSeq = useRef(0)
const editorRef = useRef<Editor | null>(null)
@ -177,7 +186,12 @@ const PostTextarea = forwardRef< @@ -177,7 +186,12 @@ const PostTextarea = forwardRef<
// `kind` catches compose-mode switches even if callback identity were ever stable across them.
// Use `jsonPanelFetchSeq` instead of an effect cleanup `cancelled` flag so a superseded fetch
// does not skip `setIsLoadingJson(false)` and leave the Json tab stuck on "Loading...".
}, [activeTab, getDraftEventJson, kind, text])
}, [activeTab, getDraftEventJson, kind, text, jsonReloadToken])
useEffect(() => {
if (activeTab !== 'json' || isLoadingJson) return
setJsonFieldValue(draftEventJson)
}, [activeTab, isLoadingJson, draftEventJson])
const editor = useEditor({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false,
@ -297,7 +311,10 @@ const PostTextarea = forwardRef< @@ -297,7 +311,10 @@ const PostTextarea = forwardRef<
getText: () => {
const editor = editorRef.current
if (editor) {
return editor.getText()
// Must match `onUpdate` / `clipboardTextSerializer` / `setDocumentFromPlainText` follow-up.
// TipTap's `editor.getText()` uses a multi-line block separator (e.g. `\n\n`), which does not
// round-trip with `plainTextToTipTapDoc` (one paragraph per `\n`) and inflates blank lines.
return parseEditorJsonToText(editor.getJSON())
}
return ''
},
@ -353,16 +370,77 @@ const PostTextarea = forwardRef< @@ -353,16 +370,77 @@ const PostTextarea = forwardRef<
/>
</div>
</TabsContent>
<TabsContent value="json">
<div className="border rounded-lg p-3 bg-muted/40 max-h-96 overflow-auto select-text">
{isLoadingJson ? (
<div className="text-muted-foreground text-sm">{t('Loading...')}</div>
) : (
<TabsContent value="json" className="mt-2 flex flex-col gap-2 min-h-0">
{isLoadingJson ? (
<div className="text-muted-foreground text-sm">{t('Loading...')}</div>
) : expectedDraftKind !== undefined && onApplyComposerDraftJson ? (
<>
<p className="text-xs text-muted-foreground">{t('Composer JSON tab hint')}</p>
<textarea
className="w-full min-h-[min(55dvh,32rem)] max-h-[min(70vh,40rem)] resize-y rounded-lg border bg-background p-3 font-mono text-xs leading-relaxed text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
spellCheck={false}
value={jsonFieldValue}
onChange={(e) => setJsonFieldValue(e.target.value)}
aria-label={t('Json')}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => {
const parsed = parseLabSlice(jsonFieldValue.trim())
if (!parsed.ok) {
return
}
if (parsed.value.kind !== expectedDraftKind) {
return
}
const ok = onApplyComposerDraftJson(jsonFieldValue)
if (ok) setJsonReloadToken((n) => n + 1)
}}
disabled={
!jsonFieldValue.trim() ||
(() => {
const p = parseLabSlice(jsonFieldValue.trim())
if (!p.ok) return true
if (p.value.kind !== expectedDraftKind) return true
return false
})()
}
>
{t('Composer JSON apply')}
</Button>
{(() => {
const p = parseLabSlice(jsonFieldValue.trim())
if (!jsonFieldValue.trim()) return null
if (!p.ok) {
return (
<span className="text-xs text-destructive" role="alert">
{p.error}
</span>
)
}
if (p.value.kind !== expectedDraftKind) {
return (
<span className="text-xs text-destructive" role="alert">
{t('composerJsonKindMismatch', {
expected: String(expectedDraftKind),
got: String(p.value.kind)
})}
</span>
)
}
return null
})()}
</div>
</>
) : (
<div className="border rounded-lg p-3 bg-muted/40 max-h-[min(70vh,40rem)] overflow-auto select-text">
<pre className="text-xs whitespace-pre-wrap break-words font-mono select-text">
{draftEventJson || t('No JSON available')}
</pre>
)}
</div>
</div>
)}
</TabsContent>
</Tabs>
)

4
src/components/ui/dropdown-menu.tsx

@ -118,7 +118,7 @@ const DropdownMenuSubContent = React.forwardRef< @@ -118,7 +118,7 @@ const DropdownMenuSubContent = React.forwardRef<
className={cn(
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
submenuBelow && 'max-w-[min(100vw-1.5rem,24rem)]',
inDialog ? 'z-[210]' : 'z-[100]'
inDialog ? 'z-[290]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {
@ -220,7 +220,7 @@ const DropdownMenuContent = React.forwardRef< @@ -220,7 +220,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[100]'
inDialog ? 'z-[290]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {

2
src/components/ui/popover.tsx

@ -24,7 +24,7 @@ const PopoverContent = React.forwardRef< @@ -24,7 +24,7 @@ const PopoverContent = React.forwardRef<
collisionPadding={10}
className={cn(
'w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[110]',
inDialog ? 'z-[290]' : 'z-[110]',
className
)}
onOpenAutoFocus={(e) => e.preventDefault()}

2
src/components/ui/select.tsx

@ -70,7 +70,7 @@ const SelectContent = React.forwardRef< @@ -70,7 +70,7 @@ const SelectContent = React.forwardRef<
ref={ref}
className={cn(
'relative max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
inDialog ? 'z-[210]' : 'z-[110]',
inDialog ? 'z-[290]' : 'z-[110]',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className

100
src/i18n/locales/de.ts

@ -956,9 +956,13 @@ export default { @@ -956,9 +956,13 @@ export default {
'Use filter hint': 'Nur unten ausgewählte Kinds werden angefragt und angezeigt.',
'Advanced event lab': 'Erweiterter Editor',
'Advanced lab hint':
'Markup und JSON (kind, content, tags) bearbeiten. id, pubkey, sig und created_at werden beim Veröffentlichen gesetzt.',
'Advanced lab markup': 'Markup',
'Advanced lab markup placeholder': 'Notiztext (Markdown oder AsciiDoc)',
'Markup hier bearbeiten. Für Kind, Tags und rohes JSON den Tab „Json“ im Formular unten nutzen und dort „JSON anwenden“.',
'Advanced lab applyError': 'Editor ist nicht bereit. Bitte erneut versuchen.',
'Advanced lab cancel undo': 'Abbrechen und Änderungen verwerfen',
'Advanced lab markup label markdown': 'Markdown',
'Advanced lab markup label asciidoc': 'AsciiDoc',
'Advanced lab markup placeholder markdown': 'Notiztext (Markdown)',
'Advanced lab markup placeholder asciidoc': 'Notiztext (AsciiDoc)',
'Advanced lab tags JSON': 'Kind, Inhalt und Tags (JSON)',
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Sprache für Grammatikprüfung',
@ -969,6 +973,96 @@ export default { @@ -969,6 +973,96 @@ export default {
'Advanced lab use translation read aloud': 'Text für Vorlesen verwenden (diese Notiz)',
'Advanced lab read aloud buffer set':
'Das nächste Vorlesen dieser Notiz nutzt den aktuellen Text (ggf. nach Übersetzung).',
'Composer JSON tab hint':
'Entwurf als JSON bearbeiten (nur `kind`, `content`, `tags`). `kind` muss zur gewählten Notizart passen. Weitere Felder werden beim Veröffentlichen gesetzt.',
'Composer JSON apply': 'JSON anwenden',
composerJsonKindMismatch:
'JSON-Kind {{got}} passt nicht zum Editor (erwartet {{expected}}). Notizart ändern oder `kind` im JSON anpassen.',
composerJsonApplySuccess: 'Entwurf aus JSON übernommen.',
'Advanced lab tb markup tools': 'Markup-Hilfen',
'Advanced lab tb headings': 'Überschriften',
'Advanced lab tb headings hint': 'Wird an der Cursorposition eingefügt; Abstände ggf. anpassen.',
'Advanced lab tb heading placeholder': 'Titel',
'Advanced lab tb h1': 'Überschrift 1 (#)',
'Advanced lab tb h2': 'Überschrift 2 (##)',
'Advanced lab tb h3': 'Überschrift 3 (###)',
'Advanced lab tb h4': 'Überschrift 4 (####)',
'Advanced lab tb h5': 'Überschrift 5 (#####)',
'Advanced lab tb h6': 'Überschrift 6 (######)',
'Advanced lab tb horizontalRule': 'Horizontale Linie (---)',
'Advanced lab tb inline': 'Inline',
'Advanced lab tb bold': 'Fett (** **)',
'Advanced lab tb italic': 'Kursiv (* *)',
'Advanced lab tb strike': 'Durchgestrichen (~~ ~~)',
'Advanced lab tb inlineCode': 'Inline-Code (` `)',
'Advanced lab tb link': 'Link [Text](URL)',
'Advanced lab tb image': 'Bild ![alt](URL)',
'Advanced lab tb lists': 'Listen',
'Advanced lab tb bulletList': 'Aufzählung (-)',
'Advanced lab tb orderedList': 'Nummerierte Liste (1.)',
'Advanced lab tb taskItem': 'Aufgabe (- [ ])',
'Advanced lab tb blocks': 'Blöcke',
'Advanced lab tb blockquote': 'Zitat (>)',
'Advanced lab tb pipeTable': 'Pipe-Tabelle (3×2 Start)',
'Advanced lab tb footnoteRef': 'Fußnotenverweis [^1]',
'Advanced lab tb footnoteDef': 'Fußnotendefinition [^1]: …',
'Advanced lab tb codeBlock': 'Codeblock',
'Advanced lab tb codeBlockHint': 'Sprache für farbige Syntax in ```…``` wählen.',
'Advanced lab tb filterLanguages': 'Sprachen filtern…',
'Advanced lab tb math': 'Formeln (KaTeX)',
'Advanced lab tb mathIntro':
'Viele Clients rendern $…$ und $$…$$ mit KaTeX; im eigenen Client prüfen.',
'Advanced lab tb mathInline': 'Inline ($…$)',
'Advanced lab tb mathDisplay': 'Abgesetzt ($$…$$)',
'Advanced lab tb mathCommon': 'Häufige Formeln',
'Advanced lab tb katexFrac': 'Bruch \\frac{a}{b}',
'Advanced lab tb katexSqrt': 'Wurzel \\sqrt{x}',
'Advanced lab tb katexSum': 'Summe \\sum_{i=1}^{n}',
'Advanced lab tb katexInt': 'Integral \\int_a^b',
'Advanced lab tb katexMatrix': '2×2-Matrix (pmatrix)',
'Advanced lab tb katexCases': 'Fallunterscheidung (cases)',
'Advanced lab tb mathGreek': 'Griechisch & Symbole',
'Advanced lab tb greekAlpha': 'alpha',
'Advanced lab tb greekBeta': 'beta',
'Advanced lab tb greekGamma': 'gamma',
'Advanced lab tb greekDelta': 'delta',
'Advanced lab tb greekPi': 'pi',
'Advanced lab tb greekTheta': 'theta',
'Advanced lab tb greekLambda': 'lambda',
'Advanced lab tb greekSigma': 'sigma',
'Advanced lab tb greekOmega': 'omega',
'Advanced lab tb greekInfty': 'Unendlich',
'Advanced lab tb hrTitle': 'Horizontale Linie',
'Advanced lab tb adocTitles': 'Titel',
'Advanced lab tb adocTitlesHint': 'AsciiDoc: = Dokumenttitel, == … für Abschnitte.',
'Advanced lab tb documentTitle': 'Dokumenttitel',
'Advanced lab tb sectionTitle': 'Abschnittstitel',
'Advanced lab tb adocLevel0': 'Dokumenttitel (=)',
'Advanced lab tb adocSection1': 'Abschnitt (==)',
'Advanced lab tb adocSection2': 'Unterabschnitt (===)',
'Advanced lab tb adocSection3': 'Ebene 4 (====)',
'Advanced lab tb adocSection4': 'Ebene 5 (=====)',
'Advanced lab tb adocSection5': 'Ebene 6 (======)',
'Advanced lab tb adocBold': 'Fett (*fett*)',
'Advanced lab tb adocItalic': 'Kursiv (_kursiv_)',
'Advanced lab tb adocMono': 'Monospace (`mono`)',
'Advanced lab tb adocLink': 'Link (link:url[Text])',
'Advanced lab tb adocImage': 'Bild (image::url[Alt])',
'Advanced lab tb adocUnordered': 'Aufzählung (* Punkt)',
'Advanced lab tb adocOrdered': 'Nummeriert (. Schritt)',
'Advanced lab tb adocLabeled': 'Label-Liste (Begriff:: Erklärung)',
'Advanced lab tb adocBlocks': 'Blöcke',
'Advanced lab tb adocQuote': 'Zitat (____)',
'Advanced lab tb adocLiteral': 'Literal (....)',
'Advanced lab tb adocNote': 'NOTE-Hinweis',
'Advanced lab tb adocTip': 'TIP-Hinweis',
'Advanced lab tb adocWarning': 'WARNING-Hinweis',
'Advanced lab tb adocSource': 'Source / Listing',
'Advanced lab tb adocSourceHint': 'Fügt [source,Sprache] ein; Beispielcode ersetzen.',
'Advanced lab tb adocStem': 'STEM / LaTeX',
'Advanced lab tb adocStemHint': 'stem:[…] (STEM in der Pipeline ggf. aktivieren).',
'Advanced lab tb adocStemInline': 'Inline stem:[…]',
'Advanced lab tb adocHrTitle': 'Thematic break (\'\'\')',
Apply: 'Anwenden',
Reset: 'Zurücksetzen',
'Share something on this Relay': 'Teile etwas auf diesem Relay',

99
src/i18n/locales/en.ts

@ -957,9 +957,13 @@ export default { @@ -957,9 +957,13 @@ export default {
'Use filter hint': 'Only the kinds you select below are requested and shown.',
'Advanced event lab': 'Advanced editor',
'Advanced lab hint':
'Edit markup and JSON (kind, content, tags). id, pubkey, sig, and created_at are assigned when you publish.',
'Advanced lab markup': 'Markup',
'Advanced lab markup placeholder': 'Note body (Markdown or AsciiDoc)',
'Edit markup here. For kind, tags, and raw JSON, use the Json tab in the composer below, then Apply JSON there.',
'Advanced lab applyError': 'Editor is not ready. Try again.',
'Advanced lab cancel undo': 'Cancel and Undo Changes',
'Advanced lab markup label markdown': 'Markdown',
'Advanced lab markup label asciidoc': 'AsciiDoc',
'Advanced lab markup placeholder markdown': 'Note body (Markdown)',
'Advanced lab markup placeholder asciidoc': 'Note body (AsciiDoc)',
'Advanced lab tags JSON': 'Kind, content, and tags (JSON)',
'Advanced lab json placeholder': '{ "kind": 1, "content": "…", "tags": [] }',
'Advanced lab grammar language': 'Grammar check language',
@ -970,6 +974,95 @@ export default { @@ -970,6 +974,95 @@ export default {
'Advanced lab use translation read aloud': 'Use body for read-aloud (this note)',
'Advanced lab read aloud buffer set':
'The next read-aloud for this note will use the current body text (translated if you translated first).',
'Composer JSON tab hint':
'Edit the draft as JSON (`kind`, `content`, `tags` only). `kind` must match the note type selected above. Other fields are set when you publish.',
'Composer JSON apply': 'Apply JSON',
composerJsonKindMismatch:
'JSON kind {{got}} does not match the composer (expected {{expected}}). Change the note type or fix `kind` in JSON.',
composerJsonApplySuccess: 'Draft updated from JSON.',
'Advanced lab tb markup tools': 'Markup helpers',
'Advanced lab tb headings': 'Headings',
'Advanced lab tb headings hint': 'Inserts at the cursor; adjust spacing if needed.',
'Advanced lab tb heading placeholder': 'Title',
'Advanced lab tb h1': 'Heading 1 (#)',
'Advanced lab tb h2': 'Heading 2 (##)',
'Advanced lab tb h3': 'Heading 3 (###)',
'Advanced lab tb h4': 'Heading 4 (####)',
'Advanced lab tb h5': 'Heading 5 (#####)',
'Advanced lab tb h6': 'Heading 6 (######)',
'Advanced lab tb horizontalRule': 'Horizontal rule (---)',
'Advanced lab tb inline': 'Inline',
'Advanced lab tb bold': 'Bold (** **)',
'Advanced lab tb italic': 'Italic (* *)',
'Advanced lab tb strike': 'Strikethrough (~~ ~~)',
'Advanced lab tb inlineCode': 'Inline code (` `)',
'Advanced lab tb link': 'Link [text](url)',
'Advanced lab tb image': 'Image ![alt](url)',
'Advanced lab tb lists': 'Lists',
'Advanced lab tb bulletList': 'Bullet list (-)',
'Advanced lab tb orderedList': 'Numbered list (1.)',
'Advanced lab tb taskItem': 'Task item (- [ ])',
'Advanced lab tb blocks': 'Blocks',
'Advanced lab tb blockquote': 'Blockquote (>)',
'Advanced lab tb pipeTable': 'Pipe table (3×2 starter)',
'Advanced lab tb footnoteRef': 'Footnote reference [^1]',
'Advanced lab tb footnoteDef': 'Footnote definition [^1]: …',
'Advanced lab tb codeBlock': 'Code block',
'Advanced lab tb codeBlockHint': 'Pick a language for syntax highlighting in fenced code.',
'Advanced lab tb filterLanguages': 'Filter languages…',
'Advanced lab tb math': 'Math (KaTeX)',
'Advanced lab tb mathIntro': 'Many readers render $…$ / $$…$$ with KaTeX; check your client.',
'Advanced lab tb mathInline': 'Inline math ($…$)',
'Advanced lab tb mathDisplay': 'Display math ($$…$$)',
'Advanced lab tb mathCommon': 'Common formulas',
'Advanced lab tb katexFrac': 'Fraction \\frac{a}{b}',
'Advanced lab tb katexSqrt': 'Square root \\sqrt{x}',
'Advanced lab tb katexSum': 'Sum \\sum_{i=1}^{n}',
'Advanced lab tb katexInt': 'Integral \\int_a^b',
'Advanced lab tb katexMatrix': '2×2 matrix (pmatrix)',
'Advanced lab tb katexCases': 'Cases (piecewise)',
'Advanced lab tb mathGreek': 'Greek & symbols',
'Advanced lab tb greekAlpha': 'alpha',
'Advanced lab tb greekBeta': 'beta',
'Advanced lab tb greekGamma': 'gamma',
'Advanced lab tb greekDelta': 'delta',
'Advanced lab tb greekPi': 'pi',
'Advanced lab tb greekTheta': 'theta',
'Advanced lab tb greekLambda': 'lambda',
'Advanced lab tb greekSigma': 'sigma',
'Advanced lab tb greekOmega': 'omega',
'Advanced lab tb greekInfty': 'infinity',
'Advanced lab tb hrTitle': 'Horizontal rule',
'Advanced lab tb adocTitles': 'Titles',
'Advanced lab tb adocTitlesHint': 'AsciiDoc uses = for the document title and == … for sections.',
'Advanced lab tb documentTitle': 'Document title',
'Advanced lab tb sectionTitle': 'Section title',
'Advanced lab tb adocLevel0': 'Document title (=)',
'Advanced lab tb adocSection1': 'Section (==)',
'Advanced lab tb adocSection2': 'Subsection (===)',
'Advanced lab tb adocSection3': 'Subsubsection (====)',
'Advanced lab tb adocSection4': 'Level 5 (=====)',
'Advanced lab tb adocSection5': 'Level 6 (======)',
'Advanced lab tb adocBold': 'Bold (*bold*)',
'Advanced lab tb adocItalic': 'Italic (_italic_)',
'Advanced lab tb adocMono': 'Monospace (`mono`)',
'Advanced lab tb adocLink': 'Link (link:url[text])',
'Advanced lab tb adocImage': 'Image (image::url[alt])',
'Advanced lab tb adocUnordered': 'Unordered (* item)',
'Advanced lab tb adocOrdered': 'Ordered (. item)',
'Advanced lab tb adocLabeled': 'Labeled list (term:: def)',
'Advanced lab tb adocBlocks': 'Blocks',
'Advanced lab tb adocQuote': 'Quote block (____)',
'Advanced lab tb adocLiteral': 'Literal block (....)',
'Advanced lab tb adocNote': 'NOTE admonition',
'Advanced lab tb adocTip': 'TIP admonition',
'Advanced lab tb adocWarning': 'WARNING admonition',
'Advanced lab tb adocSource': 'Source / listing',
'Advanced lab tb adocSourceHint': 'Inserts a [source,lang] block; replace the sample code.',
'Advanced lab tb adocStem': 'STEM / LaTeX',
'Advanced lab tb adocStemHint': 'Asciidoctor stem:[…] (enable stem in your pipeline if needed).',
'Advanced lab tb adocStemInline': 'Inline stem:[…]',
'Advanced lab tb adocHrTitle': 'Thematic break (\'\'\')',
Apply: 'Apply',
Reset: 'Reset',
'Share something on this Relay': 'Share something on this Relay',

49
src/services/post-editor-cache.service.ts

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
import { StorageKey } from '@/constants'
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import { parseEditorJsonToText } from '@/lib/tiptap'
import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types'
import { Content } from '@tiptap/react'
import { Event } from 'nostr-tools'
import { parseEditorJsonToText } from '@/lib/tiptap'
const PERSIST_DEBOUNCE_MS = 5_000
@ -32,6 +33,8 @@ type TPersistedDraft = { @@ -32,6 +33,8 @@ type TPersistedDraft = {
postContentCache: Record<string, Content>
postSettingsCache: Record<string, TPostSettings>
threadDraft: TThreadDraft | null
/** Advanced event lab (CodeMirror) drafts keyed by {@link PostEditorCacheService.generateCacheKey} or custom keys. */
advancedLabDrafts?: Record<string, AdvancedEventLabSlice>
}
class PostEditorCacheService {
@ -39,6 +42,7 @@ class PostEditorCacheService { @@ -39,6 +42,7 @@ class PostEditorCacheService {
private postContentCache: Map<string, Content> = new Map()
private postSettingsCache: Map<string, TPostSettings> = new Map()
private advancedLabDrafts: Map<string, AdvancedEventLabSlice> = new Map()
private threadDraftCache: TThreadDraft | null = null
private persistTimeoutId: ReturnType<typeof setTimeout> | null = null
private restoredFromStorage = false
@ -106,6 +110,13 @@ class PostEditorCacheService { @@ -106,6 +110,13 @@ class PostEditorCacheService {
if (data.threadDraft) {
this.threadDraftCache = data.threadDraft
}
if (data.advancedLabDrafts && typeof data.advancedLabDrafts === 'object') {
Object.entries(data.advancedLabDrafts).forEach(([k, v]) => {
if (v && typeof v === 'object' && typeof (v as AdvancedEventLabSlice).content === 'string') {
this.advancedLabDrafts.set(k, v as AdvancedEventLabSlice)
}
})
}
} catch {
// Ignore corrupt or stale data
}
@ -133,11 +144,16 @@ class PostEditorCacheService { @@ -133,11 +144,16 @@ class PostEditorCacheService {
this.postSettingsCache.forEach((v, k) => {
postSettingsCache[k] = v
})
const advancedLabDrafts: Record<string, AdvancedEventLabSlice> = {}
this.advancedLabDrafts.forEach((v, k) => {
advancedLabDrafts[k] = v
})
const data: TPersistedDraft = {
accountPubkey: account.pubkey,
postContentCache,
postSettingsCache,
threadDraft: this.threadDraftCache
threadDraft: this.threadDraftCache,
advancedLabDrafts
}
window.localStorage.setItem(StorageKey.POST_EDITOR_DRAFT, JSON.stringify(data))
} catch {
@ -153,6 +169,7 @@ class PostEditorCacheService { @@ -153,6 +169,7 @@ class PostEditorCacheService {
}
this.postContentCache.clear()
this.postSettingsCache.clear()
this.advancedLabDrafts.clear()
this.threadDraftCache = null
this.keysRestoredThisSession.clear()
this.restoredFromStorage = false
@ -207,11 +224,38 @@ class PostEditorCacheService { @@ -207,11 +224,38 @@ class PostEditorCacheService {
this.schedulePersist()
}
getAdvancedLabDraft(key: string): AdvancedEventLabSlice | undefined {
this.restoreFromStorageIfNeeded()
return this.advancedLabDrafts.get(key)
}
setAdvancedLabDraft(key: string, slice: AdvancedEventLabSlice) {
this.restoreFromStorageIfNeeded()
const copy: AdvancedEventLabSlice = {
kind: slice.kind,
content: slice.content,
tags: slice.tags.map((row) => [...row])
}
this.advancedLabDrafts.set(key, copy)
this.schedulePersist()
}
clearAdvancedLabDraft(key: string) {
this.restoreFromStorageIfNeeded()
if (!this.advancedLabDrafts.delete(key)) return
if (this.persistTimeoutId) {
clearTimeout(this.persistTimeoutId)
this.persistTimeoutId = null
}
this.persistNow()
}
clearPostCache({ kind, defaultContent, parentEvent }: TCacheKeyParams) {
const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent })
this.keysRestoredThisSession.delete(cacheKey)
this.postContentCache.delete(cacheKey)
this.postSettingsCache.delete(cacheKey)
this.advancedLabDrafts.delete(cacheKey)
if (this.persistTimeoutId) {
clearTimeout(this.persistTimeoutId)
this.persistTimeoutId = null
@ -224,6 +268,7 @@ class PostEditorCacheService { @@ -224,6 +268,7 @@ class PostEditorCacheService {
this.keysRestoredThisSession.clear()
this.postContentCache.clear()
this.postSettingsCache.clear()
this.advancedLabDrafts.clear()
if (this.persistTimeoutId) {
clearTimeout(this.persistTimeoutId)
this.persistTimeoutId = null

Loading…
Cancel
Save