You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
450 lines
16 KiB
450 lines
16 KiB
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' |
|
import customEmojiService from '@/services/custom-emoji.service' |
|
import postEditorCache from '@/services/post-editor-cache.service' |
|
import { TEmoji } from '@/types' |
|
import Document from '@tiptap/extension-document' |
|
import { HardBreak } from '@tiptap/extension-hard-break' |
|
import History from '@tiptap/extension-history' |
|
import Paragraph from '@tiptap/extension-paragraph' |
|
import Placeholder from '@tiptap/extension-placeholder' |
|
import Text from '@tiptap/extension-text' |
|
import { TextSelection } from '@tiptap/pm/state' |
|
import { Editor, EditorContent, useEditor } from '@tiptap/react' |
|
import { Event } from 'nostr-tools' |
|
import { |
|
Dispatch, |
|
forwardRef, |
|
SetStateAction, |
|
useEffect, |
|
useImperativeHandle, |
|
useMemo, |
|
useRef, |
|
useState |
|
} from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { ClipboardAndDropHandler } from './ClipboardAndDropHandler' |
|
import Emoji from './Emoji' |
|
import emojiSuggestion from './Emoji/suggestion' |
|
import Mention from './Mention' |
|
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 |
|
|
|
export type TPostTextareaHandle = { |
|
appendText: (text: string, addNewline?: boolean) => void |
|
insertText: (text: string) => void |
|
insertEmoji: (emoji: string | TEmoji) => void |
|
clear: () => void |
|
getText: () => string |
|
/** Replace editor from plain `content` (e.g. advanced lab). Syncs TipTap JSON cache and parent `text`. */ |
|
setDocumentFromPlainText: (plain: string) => void |
|
} |
|
|
|
const PostTextarea = forwardRef< |
|
TPostTextareaHandle, |
|
{ |
|
text: string |
|
setText: Dispatch<SetStateAction<string>> |
|
defaultContent?: string |
|
parentEvent?: Event |
|
onSubmit?: () => void |
|
className?: string |
|
onUploadStart?: (file: File, cancel: () => void) => void |
|
onUploadProgress?: (file: File, progress: number) => void |
|
onUploadEnd?: (file: File) => void |
|
onUploadSuccess?: (result: { |
|
url: string |
|
tags: string[][] |
|
file: File |
|
urlAlreadyInEditor?: boolean |
|
}) => void |
|
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void |
|
onUploadCompressProgress?: (file: File, percent: number) => void |
|
kind?: number |
|
highlightData?: HighlightData |
|
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?: { |
|
title?: string |
|
summary?: string |
|
image?: string |
|
dTag?: string |
|
topics?: string[] |
|
} |
|
extraPreviewTags?: string[][] |
|
addClientTag?: boolean |
|
} |
|
>( |
|
( |
|
{ |
|
text = '', |
|
setText, |
|
defaultContent, |
|
parentEvent, |
|
onSubmit, |
|
className, |
|
onUploadStart, |
|
onUploadProgress, |
|
onUploadEnd, |
|
onUploadSuccess, |
|
onUploadCompressPhase, |
|
onUploadCompressProgress, |
|
kind = 1, |
|
highlightData, |
|
pollCreateData, |
|
headerActions, |
|
getDraftEventJson, |
|
expectedDraftKind, |
|
onApplyComposerDraftJson, |
|
mediaImetaTags, |
|
mediaUrl, |
|
articleMetadata, |
|
extraPreviewTags, |
|
addClientTag = true |
|
}, |
|
ref |
|
) => { |
|
const { t } = useTranslation() |
|
const onUploadSuccessRef = useRef(onUploadSuccess) |
|
onUploadSuccessRef.current = onUploadSuccess |
|
const onUploadCompressPhaseRef = useRef(onUploadCompressPhase) |
|
onUploadCompressPhaseRef.current = onUploadCompressPhase |
|
const onUploadCompressProgressRef = useRef(onUploadCompressProgress) |
|
onUploadCompressProgressRef.current = onUploadCompressProgress |
|
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) |
|
|
|
const kindDescription = useMemo(() => getKindDescription(kind), [kind]) |
|
|
|
useEffect(() => { |
|
if (activeTab === 'preview') { |
|
jsonPanelFetchSeq.current += 1 |
|
setDraftEventJson('') |
|
setIsLoadingJson(false) |
|
return |
|
} |
|
|
|
if (activeTab !== 'json' || !getDraftEventJson) { |
|
return |
|
} |
|
|
|
const seq = ++jsonPanelFetchSeq.current |
|
setIsLoadingJson(true) |
|
|
|
let timeoutId: number | undefined = window.setTimeout(() => { |
|
timeoutId = undefined |
|
if (seq !== jsonPanelFetchSeq.current) return |
|
setDraftEventJson( |
|
`Error generating JSON: Timed out after ${Math.round(DRAFT_JSON_PREVIEW_TIMEOUT_MS / 1000)}s (relays or network slow)` |
|
) |
|
setIsLoadingJson(false) |
|
}, DRAFT_JSON_PREVIEW_TIMEOUT_MS) |
|
|
|
const clearJsonTimeout = () => { |
|
if (timeoutId !== undefined) { |
|
clearTimeout(timeoutId) |
|
timeoutId = undefined |
|
} |
|
} |
|
|
|
void Promise.resolve(getDraftEventJson()) |
|
.then((json) => { |
|
clearJsonTimeout() |
|
if (seq !== jsonPanelFetchSeq.current) return |
|
setDraftEventJson(json) |
|
setIsLoadingJson(false) |
|
}) |
|
.catch((error: unknown) => { |
|
clearJsonTimeout() |
|
if (seq !== jsonPanelFetchSeq.current) return |
|
const msg = error instanceof Error ? error.message : String(error) |
|
setDraftEventJson(`Error generating JSON: ${msg}`) |
|
setIsLoadingJson(false) |
|
}) |
|
|
|
// `text` is included so JSON refreshes when the parent memoizes `getDraftEventJson` too narrowly; |
|
// `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, 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, |
|
extensions: [ |
|
Document, |
|
Paragraph, |
|
Text, |
|
History, |
|
HardBreak, |
|
Placeholder.configure({ |
|
placeholder: |
|
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' |
|
}), |
|
Emoji.configure({ |
|
suggestion: emojiSuggestion |
|
}), |
|
Mention.configure({ |
|
suggestion: mentionSuggestion |
|
}), |
|
ClipboardAndDropHandler.configure({ |
|
onUploadStart: (file, cancel) => { |
|
onUploadStart?.(file, cancel) |
|
}, |
|
onUploadEnd: (file) => onUploadEnd?.(file), |
|
onUploadProgress: (file, p) => onUploadProgress?.(file, p), |
|
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result), |
|
onUploadCompressPhase: (file, phase) => |
|
onUploadCompressPhaseRef.current?.(file, phase), |
|
onUploadCompressProgress: (file, pct) => |
|
onUploadCompressProgressRef.current?.(file, pct) |
|
}) |
|
], |
|
editorProps: { |
|
attributes: { |
|
class: cn( |
|
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', |
|
className |
|
) |
|
}, |
|
handleKeyDown: (_view, event) => { |
|
// Handle Ctrl+Enter or Cmd+Enter for submit |
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { |
|
event.preventDefault() |
|
onSubmit?.() |
|
return true |
|
} |
|
return false |
|
}, |
|
clipboardTextSerializer(content) { |
|
return parseEditorJsonToText(content.toJSON()) |
|
} |
|
}, |
|
content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }), |
|
onUpdate(props) { |
|
setText(parseEditorJsonToText(props.editor.getJSON())) |
|
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON()) |
|
}, |
|
onCreate(props) { |
|
setText(parseEditorJsonToText(props.editor.getJSON())) |
|
} |
|
}) |
|
|
|
editorRef.current = editor |
|
|
|
useImperativeHandle(ref, () => ({ |
|
appendText: (text: string, addNewline = false) => { |
|
const ed = editorRef.current |
|
if (ed) { |
|
let chain = ed |
|
.chain() |
|
.focus() |
|
.command(({ tr, dispatch }) => { |
|
if (dispatch) { |
|
const endPos = tr.doc.content.size |
|
const selection = TextSelection.create(tr.doc, endPos) |
|
tr.setSelection(selection) |
|
dispatch(tr) |
|
} |
|
return true |
|
}) |
|
.insertContent(text) |
|
if (addNewline) { |
|
chain = chain.setHardBreak() |
|
} |
|
chain.run() |
|
} |
|
}, |
|
insertText: (text: string) => { |
|
const editor = editorRef.current |
|
if (editor) { |
|
editor.chain().focus().insertContent(text).run() |
|
} |
|
}, |
|
insertEmoji: (emoji: string | TEmoji) => { |
|
const editor = editorRef.current |
|
if (editor) { |
|
if (typeof emoji === 'string') { |
|
editor.chain().insertContent(emoji).run() |
|
} else { |
|
const emojiNode = editor.schema.nodes.emoji.create({ |
|
name: customEmojiService.getEmojiId(emoji) |
|
}) |
|
editor.chain().insertContent(emojiNode).insertContent(' ').run() |
|
} |
|
} |
|
}, |
|
clear: () => { |
|
const editor = editorRef.current |
|
if (editor) { |
|
// Clear the editor content and reset to empty document |
|
editor.chain().clearContent().run() |
|
// Also clear the cache |
|
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) |
|
setText('') |
|
} |
|
}, |
|
getText: () => { |
|
const editor = editorRef.current |
|
if (editor) { |
|
// 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 '' |
|
}, |
|
setDocumentFromPlainText: (plain: string) => { |
|
const editor = editorRef.current |
|
if (!editor) return |
|
const json = plainTextToTipTapDoc(plain) |
|
editor.chain().setContent(json).run() |
|
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) |
|
setText(parseEditorJsonToText(editor.getJSON())) |
|
} |
|
})) |
|
|
|
if (!editor) { |
|
return null |
|
} |
|
|
|
return ( |
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2"> |
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> |
|
<TabsList className="w-auto justify-start"> |
|
<TabsTrigger value="preview" title={t('Preview')}> |
|
{t('Preview')} |
|
</TabsTrigger> |
|
<TabsTrigger value="json" title={t('Json')}> |
|
{t('Json')} |
|
</TabsTrigger> |
|
</TabsList> |
|
{headerActions && ( |
|
<div className="flex gap-1 items-center flex-wrap"> |
|
{headerActions} |
|
</div> |
|
)} |
|
</div> |
|
{/* Editor always visible (no Edit tab). Keep mounted; only Preview/Json swap panels below. */} |
|
<EditorContent className="tiptap" editor={editor} /> |
|
<TabsContent value="preview"> |
|
<div className="space-y-2"> |
|
<div className="text-xs text-muted-foreground"> |
|
kind {kindDescription.number}: {kindDescription.description} |
|
</div> |
|
<Preview |
|
content={text} |
|
className={className} |
|
kind={kind} |
|
highlightData={highlightData} |
|
pollCreateData={pollCreateData} |
|
mediaImetaTags={mediaImetaTags} |
|
mediaUrl={mediaUrl} |
|
articleMetadata={articleMetadata} |
|
extraPreviewTags={extraPreviewTags} |
|
addClientTag={addClientTag} |
|
/> |
|
</div> |
|
</TabsContent> |
|
<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> |
|
)} |
|
</TabsContent> |
|
</Tabs> |
|
) |
|
} |
|
) |
|
PostTextarea.displayName = 'PostTextarea' |
|
export default PostTextarea
|
|
|