Anton Tranelis 319f4a5057 refactor(TextView): migrate from react-markdown to TipTap
Replace react-markdown with TipTap read-only editor for TextView component.

Changes:
- Create TipTap extensions folder with custom Hashtag and VideoEmbed nodes
- Hashtag extension: clickable hashtags with tag colors and filter integration
- VideoEmbed extension: YouTube and Rumble iframe embeds
- Add preprocessMarkdown utility for URL, email, video link, and hashtag conversion
- Migrate TextView to use TipTap with StarterKit, Markdown, Link extensions
- Remove unused itemId prop from TextView and all callers

Known issue: Popup buttons may not work correctly when TextView has content
due to Leaflet's handling of contenteditable elements. To be fixed in follow-up.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:58:43 +01:00

145 lines
3.6 KiB
TypeScript

import { Link } from '@tiptap/extension-link'
import { EditorContent, useEditor } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Markdown } from 'tiptap-markdown'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useTags } from '#components/Map/hooks/useTags'
import { Hashtag } from '#components/TipTap/extensions/Hashtag'
import { VideoEmbed } from '#components/TipTap/extensions/VideoEmbed'
import {
preprocessMarkdown,
removeMarkdownSyntax,
truncateMarkdown,
} from '#components/TipTap/utils/preprocessMarkdown'
import type { Item } from '#types/Item'
/**
* @category Map
*/
export const TextView = ({
item,
text,
truncate = false,
rawText,
}: {
item?: Item
text?: string | null
truncate?: boolean
rawText?: string
}) => {
if (item) {
text = item.text
}
const tags = useTags()
const addFilterTag = useAddFilterTag()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
// Prepare the text content
let innerText = ''
if (rawText) {
innerText = rawText
} else if (text === undefined) {
// Field was omitted by backend (no permission)
innerText = `[Login](/login) to see this ${item?.layer?.item_default_name ?? 'item'}`
} else if (text === null || text === '') {
// Field is not set or empty - show nothing
innerText = ''
} else {
// Field has a value
innerText = text
}
// Apply truncation if needed
if (innerText && truncate) {
innerText = truncateMarkdown(removeMarkdownSyntax(innerText), 100)
}
// Pre-process the markdown
const processedText = innerText ? preprocessMarkdown(innerText) : ''
const editor = useEditor(
{
extensions: [
StarterKit,
Markdown.configure({
html: true, // Allow HTML in markdown (for our preprocessed tags)
transformPastedText: true,
}),
Link.configure({
openOnClick: false, // We handle clicks ourselves
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
Hashtag.configure({
tags,
onTagClick: (tag) => {
addFilterTag(tag)
},
}),
VideoEmbed,
],
content: processedText,
editable: false,
editorProps: {
attributes: {
class: 'markdown tw:text-map tw:leading-map tw:text-sm',
},
},
},
[processedText, tags],
)
// Update content when text changes
useEffect(() => {
editor.commands.setContent(processedText)
}, [editor, processedText])
// Handle link clicks for internal navigation
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const link = target.closest('a')
if (!link) return
const href = link.getAttribute('href')
if (!href) return
// Internal links → React Router navigation
if (href.startsWith('/')) {
e.preventDefault()
e.stopPropagation()
void navigate(href)
}
// External links are handled by the Link extension (target="_blank")
}
container.addEventListener('click', handleClick)
return () => {
container.removeEventListener('click', handleClick)
}
}, [navigate])
if (!innerText) {
return null
}
return (
<div translate='no' ref={containerRef}>
<EditorContent editor={editor} />
</div>
)
}