From 065fb15d84633e348462fe10708fe5fc86398a1c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 15 Apr 2026 15:53:26 +0200 Subject: [PATCH] fix editor --- .../AdvancedEventLabDialog.tsx | 482 +++++++---- .../AdvancedEventLabMarkupToolbar.tsx | 753 ++++++++++++++++++ .../AdvancedEventLab/markup-insert.ts | 66 ++ src/components/EmojiPicker/index.tsx | 21 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 186 ++++- .../NoteOptions/EditOrCloneEventDialog.tsx | 17 + src/components/PostEditor/PostContent.tsx | 329 +++++--- .../PostEditor/PostEditorFormatToolbar.tsx | 122 +++ .../PostTextarea/Mention/MentionList.tsx | 2 +- .../PostEditor/PostTextarea/index.tsx | 96 ++- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/i18n/locales/de.ts | 100 ++- src/i18n/locales/en.ts | 99 ++- src/services/post-editor-cache.service.ts | 49 +- 16 files changed, 1993 insertions(+), 337 deletions(-) create mode 100644 src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx create mode 100644 src/components/AdvancedEventLab/markup-insert.ts create mode 100644 src/components/PostEditor/PostEditorFormatToolbar.tsx diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index d1da5dae..c6485485 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -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 { 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 = { /** 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 + /** 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({ markupMode, i18nLanguage, contextEventId, - onApply + onApply, + bodyApiRef, + formatToolbar, + draftPersistenceKey = null }: AdvancedEventLabDialogProps) { const { t, i18n } = useTranslation() const dark = useDarkModeFlag() const markupHost = useRef(null) - const jsonHost = useRef(null) const markupView = useRef(null) - const jsonView = useRef(null) const sliceRef = useRef(null) - const syncing = useRef(false) + const draftPersistenceKeyRef = useRef(null) + const labPersistTimerRef = useRef | 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(null) const [translateTarget, setTranslateTarget] = useState('en') useEffect(() => { @@ -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({ 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({ } return ( - - + + {t('Advanced event lab')} @@ -354,35 +484,33 @@ export default function AdvancedEventLabDialog({ ) : null} - {jsonError ? ( -

- {jsonError} -

- ) : null} -
-
- {t('Advanced lab markup')} -
-
-
- {t('Advanced lab tags JSON')} -
-
+ + +
+ + {t( + markupMode === 'asciidoc' + ? 'Advanced lab markup label asciidoc' + : 'Advanced lab markup label markdown' + )} + +
+ {formatToolbar ? ( +
{formatToolbar}
+ ) : null} + - - @@ -390,3 +518,13 @@ export default function AdvancedEventLabDialog({
) } + +/** 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(' ') +} diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx new file mode 100644 index 00000000..7a962f3f --- /dev/null +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -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 + sliceRef: MutableRefObject +} + +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 ( +
+ + {t('Advanced lab tb markup tools')} + + + + + + + + {t('Advanced lab tb headings hint')} + {( + [ + '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 ( + + run((v) => + labInsertRaw( + v, + sliceRef, + `${n === 1 ? '' : '\n'}${'#'.repeat(n)} ${t('Advanced lab tb heading placeholder')}\n` + ) + ) + } + > + {t(labelKey)} + + ) + })} + + run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}> + {t('Advanced lab tb horizontalRule')} + + + + + + + + + + run((v) => labWrapOrSnippet(v, sliceRef, '**', 'bold'))}> + {t('Advanced lab tb bold')} + + run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}> + {t('Advanced lab tb italic')} + + run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}> + {t('Advanced lab tb strike')} + + run((v) => labWrapOrSnippet(v, sliceRef, '`', 'code'))}> + {t('Advanced lab tb inlineCode')} + + + + run((v) => + labInsertSnippet(v, sliceRef, '[', 'link text', '](https://example.com)') + ) + } + > + + {t('Advanced lab tb link')} + + + run((v) => + labInsertSnippet(v, sliceRef, '![', 'alt text', '](https://example.com/image.png)') + ) + } + > + + {t('Advanced lab tb image')} + + + + + + + + + + run((v) => labInsertRaw(v, sliceRef, '\n- item one\n- item two\n'))}> + + {t('Advanced lab tb bulletList')} + + run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}> + + {t('Advanced lab tb orderedList')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n- [x] Checked box\n- [ ] Unchecked box\n' + ) + ) + } + > + + {t('Advanced lab tb taskItem')} + + + + + + + + + + run((v) => labInsertRaw(v, sliceRef, '\n> quoted line\n'))}> + + {t('Advanced lab tb blockquote')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n| Col A | Col B |\n| --- | --- |\n| cell | cell |\n' + ) + ) + } + > + + {t('Advanced lab tb pipeTable')} + + + run((v) => labInsertRaw(v, sliceRef, '\n[^1]\n'))}> + {t('Advanced lab tb footnoteRef')} + + + run((v) => + labInsertRaw(v, sliceRef, '\n[^1]: Footnote text goes here.\n') + ) + } + > + {t('Advanced lab tb footnoteDef')} + + + + + !o && setCodeFilter('')}> + + + + +

{t('Advanced lab tb codeBlockHint')}

+ setCodeFilter(e.target.value)} + placeholder={t('Advanced lab tb filterLanguages')} + className="h-8 text-xs mb-2" + /> + +
+ {filteredLangs.map((lang) => ( + + ))} +
+
+
+
+ + + + + + + {t('Advanced lab tb mathIntro')} + + + + run((v) => + labInsertSnippet(v, sliceRef, '$', 'x^2 + y^2 = r^2', '$') + ) + } + > + {t('Advanced lab tb mathInline')} + + + run((v) => + labInsertSnippet(v, sliceRef, '\n$$\n', 'E = mc^2', '\n$$\n') + ) + } + > + {t('Advanced lab tb mathDisplay')} + + + + {t('Advanced lab tb mathCommon')} + run((v) => labInsertRaw(v, sliceRef, '$\\frac{numerator}{denominator}$'))} + > + {t('Advanced lab tb katexFrac')} + + run((v) => labInsertRaw(v, sliceRef, '$\\sqrt{x}$'))}> + {t('Advanced lab tb katexSqrt')} + + run((v) => labInsertRaw(v, sliceRef, '$\\sum_{i=1}^{n} i$'))} + > + {t('Advanced lab tb katexSum')} + + run((v) => labInsertRaw(v, sliceRef, '$\\int_{a}^{b} f(x)\\,dx$'))} + > + {t('Advanced lab tb katexInt')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$' + ) + ) + } + > + {t('Advanced lab tb katexMatrix')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '$$\\begin{cases} x & x > 0 \\\\ -x & x \\le 0 \\end{cases}$$' + ) + ) + } + > + {t('Advanced lab tb katexCases')} + + + {t('Advanced lab tb mathGreek')} + run((v) => labInsertRaw(v, sliceRef, '$\\alpha$'))}> + {'\\alpha'} {t('Advanced lab tb greekAlpha')} + + run((v) => labInsertRaw(v, sliceRef, '$\\beta$'))}> + {'\\beta'} {t('Advanced lab tb greekBeta')} + + run((v) => labInsertRaw(v, sliceRef, '$\\gamma$'))}> + {'\\gamma'} {t('Advanced lab tb greekGamma')} + + run((v) => labInsertRaw(v, sliceRef, '$\\delta$'))}> + {'\\delta'} {t('Advanced lab tb greekDelta')} + + run((v) => labInsertRaw(v, sliceRef, '$\\pi$'))}> + {'\\pi'} {t('Advanced lab tb greekPi')} + + run((v) => labInsertRaw(v, sliceRef, '$\\theta$'))}> + {'\\theta'} {t('Advanced lab tb greekTheta')} + + run((v) => labInsertRaw(v, sliceRef, '$\\lambda$'))}> + {'\\lambda'} {t('Advanced lab tb greekLambda')} + + run((v) => labInsertRaw(v, sliceRef, '$\\sigma$'))}> + {'\\sigma'} {t('Advanced lab tb greekSigma')} + + run((v) => labInsertRaw(v, sliceRef, '$\\omega$'))}> + {'\\omega'} {t('Advanced lab tb greekOmega')} + + run((v) => labInsertRaw(v, sliceRef, '$\\infty$'))}> + {'\\infty'} {t('Advanced lab tb greekInfty')} + + + + + +
+ ) + } + + /* AsciiDoc */ + return ( +
+ + {t('Advanced lab tb markup tools')} + + + + + + + + {t('Advanced lab tb adocTitlesHint')} + run((v) => labInsertRaw(v, sliceRef, `\n= ${t('Advanced lab tb documentTitle')}\n`))}> + {t('Advanced lab tb adocLevel0')} + + {( + [ + '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 ( + + run((v) => + labInsertRaw( + v, + sliceRef, + `\n${'='.repeat(n + 1)} ${t('Advanced lab tb sectionTitle')}\n` + ) + ) + } + > + {t(labelKey)} + + ) + })} + + + + + + + + + run((v) => labWrapOrSnippet(v, sliceRef, '*', 'bold'))}> + {t('Advanced lab tb adocBold')} + + run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> + {t('Advanced lab tb adocItalic')} + + run((v) => labWrapOrSnippet(v, sliceRef, '`', 'mono'))}> + {t('Advanced lab tb adocMono')} + + + + run((v) => + labInsertSnippet(v, sliceRef, 'link:https://example.com[', 'link text', ']') + ) + } + > + + {t('Advanced lab tb adocLink')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + '\nimage::https://example.com/image.png[Alt text, width=640]\n' + ) + ) + } + > + + {t('Advanced lab tb adocImage')} + + + + + + + + + + run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> + {t('Advanced lab tb adocUnordered')} + + run((v) => labInsertRaw(v, sliceRef, '\n. first step\n. second step\n'))}> + {t('Advanced lab tb adocOrdered')} + + run((v) => labInsertRaw(v, sliceRef, '\nterm:: definition line\n'))}> + {t('Advanced lab tb adocLabeled')} + + + + + + + + + + + run((v) => + labInsertSnippet(v, sliceRef, '\n____\n', 'Quoted paragraph', '\n____\n') + ) + } + > + {t('Advanced lab tb adocQuote')} + + + run((v) => + labInsertSnippet(v, sliceRef, '\n....\n', 'Literal monospace block', '\n....\n') + ) + } + > + {t('Advanced lab tb adocLiteral')} + + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[NOTE]\n====\n', + 'Note body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocNote')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[TIP]\n====\n', + 'Tip body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocTip')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '\n[WARNING]\n====\n', + 'Warning body', + '\n====\n' + ) + ) + } + > + {t('Advanced lab tb adocWarning')} + + + + + !o && setLangFilter('')}> + + + + +

{t('Advanced lab tb adocSourceHint')}

+ setLangFilter(e.target.value)} + placeholder={t('Advanced lab tb filterLanguages')} + className="h-8 text-xs mb-2" + /> + +
+ {CODE_LANGUAGES.filter((id) => + langFilter.trim() ? id.toLowerCase().includes(langFilter.trim().toLowerCase()) : true + ).map((lang) => ( + + ))} +
+
+
+
+ + + + + + + {t('Advanced lab tb adocStemHint')} + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))} + > + {t('Advanced lab tb adocStemInline')} + + + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\frac{a}{b}', ']'))} + > + {t('Advanced lab tb katexFrac')} + + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sqrt{x}', ']'))}> + {t('Advanced lab tb katexSqrt')} + + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\sum_{i=1}^{n} i', ']'))} + > + {t('Advanced lab tb katexSum')} + + run((v) => labInsertSnippet(v, sliceRef, 'stem:[', '\\int_{a}^{b} f(x)\\,dx', ']'))} + > + {t('Advanced lab tb katexInt')} + + + + + +
+ ) +} diff --git a/src/components/AdvancedEventLab/markup-insert.ts b/src/components/AdvancedEventLab/markup-insert.ts new file mode 100644 index 00000000..1b1f09d3 --- /dev/null +++ b/src/components/AdvancedEventLab/markup-insert.ts @@ -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) + } +} diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx index 35ba86a4..ff85794e 100644 --- a/src/components/EmojiPicker/index.tsx +++ b/src/components/EmojiPicker/index.tsx @@ -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) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 3e7f1ce5..d799b92d 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -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 { 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 = { + 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(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: window.clearTimeout(timeoutId) } }, [code, language]) - + return ( -
-
+    
+ {label ? ( +
+ {label} +
+ ) : null} +
         
) break + case 'checkbox': { + const checked = Boolean(token.checked) + out.push( + + {checked ? '\u2611' : '\u2610'} + + ) + break + } case 'link': { const href = String(token.href ?? '') const children = stripNestedAnchorsFromNodes( @@ -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 ( +
+ {displayMathSplit.map((seg, idx) => + seg.kind === 'math' ? ( + + ) : ( +

+ {renderInlineTokens( + lexInlineProtected(seg.text.trim()), + `${key}-dmt-${idx}` + )} +

+ ) + )} +
+ ) + } const standaloneMath = parseDelimitedMath(rawParagraphText.trim()) if (standaloneMath) { return ( @@ -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( } return renderBlockTokens(itemTokens, itemKey) } + + const listBody = React.createElement( + ListTag, + { className: listClass }, + items.map((item: any, itemIdx: number) => ( +
  • + {isTaskList && token.ordered ? ( + + {startNum + itemIdx}. + + ) : null} + {isTaskList ? ( +
    + {renderListItemContent(item, `${key}-li-${itemIdx}`)} +
    + ) : ( + renderListItemContent(item, `${key}-li-${itemIdx}`) + )} +
  • + )) + ) nodes.push( - React.createElement( - ListTag, - { key: `${key}-list`, className: listClass }, - (token.items ?? []).map((item: any, itemIdx: number) => ( -
  • - {renderListItemContent(item, `${key}-li-${itemIdx}`)} -
  • - )) + isTaskList ? ( +
    + {listBody} +
    + ) : ( + React.cloneElement(listBody, { key: `${key}-list` }) ) ) break diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index 0da58fa2..f3d65733 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -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 [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 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]) : [['', '']]) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 86080dc5..a3bdffda 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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 { 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 { Book, Check, ChevronDown, - ImageUp, ListTodo, MessageCircle, MessagesSquare, - Settings, - Smile, Users, X, Highlighter, @@ -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' 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' 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({ const textareaRef = useRef(null) const labTagOverrideRef = useRef(null) const [advancedLabOpen, setAdvancedLabOpen] = useState(false) + const advancedLabOpenRef = useRef(false) + useEffect(() => { + advancedLabOpenRef.current = advancedLabOpen + }, [advancedLabOpen]) + const advancedLabBodyApiRef = useRef(null) + const getActiveComposerBody = () => + advancedLabOpenRef.current && advancedLabBodyApiRef.current + ? advancedLabBodyApiRef.current + : textareaRef.current const [advancedLabInitial, setAdvancedLabInitial] = useState(null) const mediaUploaderBtnRef = useRef(null) const [posting, setPosting] = useState(false) @@ -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({ 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({ } }, [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({ 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({ 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({ 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({ // 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({ 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({ uploadedMediaFileMap.current.clear() } + const toolbarUploadHandlers = useMemo( + () => ({ + 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({ 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({ )}
    - {/* Audio button for replies and new PMs - placed before image button */} - {(parentEvent || isPublicMessage) && ( - - - - )} - - - - - {/* 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() && ( - { - if (!emoji) return - textareaRef.current?.insertEmoji(emoji) - }} - > - - - )} - { - textareaRef.current?.insertText(gifUrl) - }} - > - - - { - textareaRef.current?.insertText(memeUrl) - }} - > - - - - textareaRef.current?.insertText(text)} - variant="ghost" + 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)} /> -
    { + 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) diff --git a/src/components/PostEditor/PostEditorFormatToolbar.tsx b/src/components/PostEditor/PostEditorFormatToolbar.tsx new file mode 100644 index 00000000..76d9a835 --- /dev/null +++ b/src/components/PostEditor/PostEditorFormatToolbar.tsx @@ -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 ( +
    + {showAudioUpload && ( + + + + )} + + + + + {!isTouchDevice() && ( + { + if (emoji == null) return + insertEmoji(emoji) + }} + > + + + )} + insertText(gifUrl)}> + + + insertText(memeUrl)}> + + + + + +
    + ) +} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index fd3c4b8b..8e127122 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -103,7 +103,7 @@ const MentionList = forwardRef((props, ref)
    e.stopPropagation()} onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 256ed8fc..a62d9906 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -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' 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< pollCreateData?: import('@/types').TPollCreateData headerActions?: React.ReactNode getDraftEventJson?: () => Promise + /** 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< pollCreateData, headerActions, getDraftEventJson, + expectedDraftKind, + onApplyComposerDraftJson, mediaImetaTags, mediaUrl, articleMetadata, @@ -121,6 +128,8 @@ const PostTextarea = forwardRef< const [activeTab, setActiveTab] = useState('preview') const [draftEventJson, setDraftEventJson] = useState('') 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(null) @@ -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< 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< />
    - -
    - {isLoadingJson ? ( -
    {t('Loading...')}
    - ) : ( + + {isLoadingJson ? ( +
    {t('Loading...')}
    + ) : expectedDraftKind !== undefined && onApplyComposerDraftJson ? ( + <> +

    {t('Composer JSON tab hint')}

    +