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.
 
 
 
 

390 lines
13 KiB

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 { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
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'
export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void
insertText: (text: string) => void
insertEmoji: (emoji: string | TEmoji) => void
clear: () => void
/** Re-read `postEditorCache` / `defaultContent` into TipTap (dialog reopened; initial `content` only runs once). */
syncFromPostCache: () => 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
mediaImetaTags?: string[][]
mediaUrl?: string
articleMetadata?: {
title?: string
summary?: string
image?: string
dTag?: string
topics?: string[]
affectedKinds?: number[]
}
musicTrackMetadata?: {
dTag?: string
title?: string
audioUrl?: string
artist?: string
imageUrl?: string
album?: string
durationSec?: number
format?: string
language?: string
genres?: string[]
}
extraPreviewTags?: string[][]
addClientTag?: boolean
}
>(
(
{
text = '',
setText,
defaultContent,
parentEvent,
onSubmit,
className,
onUploadStart,
onUploadProgress,
onUploadEnd,
onUploadSuccess,
onUploadCompressPhase,
onUploadCompressProgress,
kind = 1,
highlightData,
pollCreateData,
headerActions,
mediaImetaTags,
mediaUrl,
articleMetadata,
musicTrackMetadata,
extraPreviewTags,
addClientTag = true
},
ref
) => {
const { t } = useTranslation()
const isSmallScreen = useScreenSizeOptional()?.isSmallScreen ?? false
const onUploadSuccessRef = useRef(onUploadSuccess)
onUploadSuccessRef.current = onUploadSuccess
const onUploadCompressPhaseRef = useRef(onUploadCompressPhase)
onUploadCompressPhaseRef.current = onUploadCompressPhase
const onUploadCompressProgressRef = useRef(onUploadCompressProgress)
onUploadCompressProgressRef.current = onUploadCompressProgress
const onUploadStartRef = useRef(onUploadStart)
onUploadStartRef.current = onUploadStart
const onUploadEndRef = useRef(onUploadEnd)
onUploadEndRef.current = onUploadEnd
const onUploadProgressRef = useRef(onUploadProgress)
onUploadProgressRef.current = onUploadProgress
const onSubmitRef = useRef(onSubmit)
onSubmitRef.current = onSubmit
const [activeTab, setActiveTab] = useState('edit')
const editorRef = useRef<Editor | null>(null)
const kindDescription = useMemo(() => getKindDescription(kind), [kind])
const placeholderText = useMemo(
() => t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')',
[t]
)
// Extension instances must be stable — recreating them each render makes useEditor destroy/recreate
// the editor (blank composer in reply dialog).
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
History,
HardBreak,
Placeholder.configure({ placeholder: placeholderText }),
Emoji.configure({ suggestion: emojiSuggestion }),
Mention.configure({ suggestion: mentionSuggestion }),
ClipboardAndDropHandler.configure({
onUploadStart: (file, cancel) => onUploadStartRef.current?.(file, cancel),
onUploadEnd: (file) => onUploadEndRef.current?.(file),
onUploadProgress: (file, p) => onUploadProgressRef.current?.(file, p),
onUploadSuccess: (result) => onUploadSuccessRef.current?.(result),
onUploadCompressPhase: (file, phase) =>
onUploadCompressPhaseRef.current?.(file, phase),
onUploadCompressProgress: (file, pct) =>
onUploadCompressProgressRef.current?.(file, pct)
})
],
[placeholderText]
)
const editorSurfaceClass = useMemo(
() =>
cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
),
[className]
)
const editor = useEditor({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false,
extensions,
editorProps: {
attributes: {
class: editorSurfaceClass
},
handleKeyDown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault()
onSubmitRef.current?.()
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
useEffect(() => {
if (!editor) return
editor.setOptions({
editorProps: {
...editor.options.editorProps,
attributes: { class: editorSurfaceClass }
}
})
}, [editor, editorSurfaceClass])
useEffect(() => {
if (!editor || !isSmallScreen) return
const scrollEditorIntoView = () => {
requestAnimationFrame(() => {
editor.view.dom.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
}
editor.on('focus', scrollEditorIntoView)
return () => {
editor.off('focus', scrollEditorIntoView)
}
}, [editor, isSmallScreen])
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('')
}
},
syncFromPostCache: () => {
const editor = editorRef.current
if (!editor) return
const next = postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent })
if (next === undefined) return
editor.chain().setContent(next).run()
const json = editor.getJSON()
setText(parseEditorJsonToText(json))
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json)
},
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()))
}
}))
const editorShellClass = cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
)
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex min-w-0 flex-col gap-2">
<TabsList className="w-auto shrink-0 justify-start">
<TabsTrigger value="edit" title={t('Edit')}>
{t('Edit')}
</TabsTrigger>
<TabsTrigger value="preview" title={t('Preview')}>
{t('Preview')}
</TabsTrigger>
</TabsList>
{headerActions ? (
<div className="flex min-w-0 flex-nowrap items-center justify-end gap-1 overflow-x-auto overscroll-x-contain">
{headerActions}
</div>
) : null}
</div>
<TabsContent
value="edit"
forceMount
className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0"
>
{editor ? (
<EditorContent className="tiptap" editor={editor} />
) : (
<div
className={cn(editorShellClass, 'text-muted-foreground')}
aria-hidden
>
{placeholderText}
</div>
)}
</TabsContent>
<TabsContent
value="preview"
className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0"
>
<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}
musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag}
/>
</div>
</TabsContent>
</Tabs>
)
}
)
PostTextarea.displayName = 'PostTextarea'
export default PostTextarea