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>
This commit is contained in:
Anton Tranelis 2026-01-14 09:58:43 +01:00
parent b7f5b0092f
commit 319f4a5057
14 changed files with 429 additions and 152 deletions

View File

@ -1,55 +1,53 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import Markdown from 'react-markdown'
import { Link as RouterLink } from 'react-router-dom'
import remarkBreaks from 'remark-breaks'
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 { decodeTag } from '#utils/FormatTags'
import { hashTagRegex } from '#utils/HashTagRegex'
import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
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'
import type { Tag } from '#types/Tag'
/**
* @category Map
*/
export const TextView = ({
item,
itemId,
text,
truncate = false,
rawText,
}: {
item?: Item
itemId?: string
text?: string | null
truncate?: boolean
rawText?: string
}) => {
if (item) {
text = item.text
itemId = item.id
}
const tags = useTags()
const addFilterTag = useAddFilterTag()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
// Prepare the text content
let innerText = ''
let replacedText = ''
if (rawText) {
innerText = replacedText = rawText
innerText = rawText
} else if (text === undefined) {
// Field was omitted by backend (no permission)
innerText = replacedText = `[Login](/login) to see this ${
item?.layer?.item_default_name ?? 'item'
}`
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 = ''
@ -58,138 +56,89 @@ export const TextView = ({
innerText = text
}
if (innerText && truncate)
innerText = truncateText(removeMarkdownKeepLinksAndParagraphs(innerText), 100)
if (innerText) replacedText = fixUrls(innerText)
if (replacedText) {
replacedText = replacedText.replace(
/(?<!\]?\()(?<!<)https?:\/\/[^\s)]+(?!\))(?!>)/g,
(url) => `[${url.replace(/https?:\/\/w{3}\./gi, '')}](${url})`,
)
// Apply truncation if needed
if (innerText && truncate) {
innerText = truncateMarkdown(removeMarkdownSyntax(innerText), 100)
}
if (replacedText) {
replacedText = replacedText.replace(mailRegex, (url) => {
return `[${url}](mailto:${url})`
})
}
// Pre-process the markdown
const processedText = innerText ? preprocessMarkdown(innerText) : ''
if (replacedText) {
replacedText = replacedText.replace(hashTagRegex, (match) => {
return `[${match}](${match})`
})
}
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],
)
const HashTag = ({ children, tag, itemId }: { children: string; tag: Tag; itemId?: string }) => {
return (
<a
className='hashtag'
style={
tag && {
color: tag.color,
}
}
key={tag ? tag.name + itemId : itemId}
onClick={(e) => {
e.stopPropagation()
addFilterTag(tag)
}}
>
{decodeTag(children)}
</a>
)
}
// Update content when text changes
useEffect(() => {
editor.commands.setContent(processedText)
}, [editor, processedText])
const Link = ({ href, children }: { href: string; children: string }) => {
// Youtube
if (href.startsWith('https://www.youtube.com/watch?v=')) {
const videoId = href?.split('v=')[1].split('&')[0]
const youtubeEmbedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
// Handle link clicks for internal navigation
useEffect(() => {
const container = containerRef.current
if (!container) return
return (
<iframe src={youtubeEmbedUrl} allow='fullscreen; picture-in-picture' allowFullScreen />
)
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")
}
// Rumble
if (href.startsWith('https://rumble.com/embed/')) {
return <iframe src={href} allow='fullscreen; picture-in-picture' allowFullScreen />
container.addEventListener('click', handleClick)
return () => {
container.removeEventListener('click', handleClick)
}
}, [navigate])
// HashTag
if (href.startsWith('#')) {
const tag = tags.find((t) => t.name.toLowerCase() === decodeURI(href).slice(1).toLowerCase())
if (tag)
return (
<HashTag tag={tag} itemId={itemId}>
{children}
</HashTag>
)
else return children
}
// Internal Link (React Router)
if (href.startsWith('/')) {
return <RouterLink to={href}>{children}</RouterLink>
}
// External Link
return (
<a href={href} target='_blank' rel='noreferrer'>
{children}
</a>
)
if (!innerText) {
return null
}
return (
<div translate='no'>
<Markdown
className={'markdown tw:text-map tw:leading-map tw:text-sm'}
remarkPlugins={[remarkBreaks]}
components={{
a: Link,
}}
>
{replacedText}
</Markdown>
<div translate='no' ref={containerRef}>
<EditorContent editor={editor} />
</div>
)
}
function removeMarkdownKeepLinksAndParagraphs(text) {
// Remove Markdown syntax using regular expressions but keep links and paragraphs
return text
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
.replace(/^\s*\n/gm, '\n') // Preserve empty lines
.replace(/(\r\n|\n|\r)/gm, '\n') // Preserve line breaks
}
function truncateText(text, limit) {
if (text.length <= limit) {
return text
}
let truncated = ''
let length = 0
// Split the text by paragraphs
const paragraphs = text.split('\n')
for (const paragraph of paragraphs) {
if (length + paragraph.length > limit) {
truncated += paragraph.slice(0, limit - length) + '...'
break
} else {
truncated += paragraph + '\n'
length += paragraph.length
}
}
return truncated.trim()
}

View File

@ -104,7 +104,7 @@ export const ItemViewPopup = forwardRef((props: ItemViewPopupProps, ref: any) =>
loading={loading}
/>
<div className='tw:overflow-hidden tw:max-h-64 fade'>
{props.children ?? <TextView text={props.item.text} itemId={props.item.id} />}
{props.children ?? <TextView text={props.item.text} />}
</div>
<div className='tw:flex tw:-mb-1 tw:flex-row tw:mr-2 tw:mt-1'>
{infoExpanded ? (

View File

@ -122,10 +122,9 @@ export function UtopiaMapInner({
setTimeout(() => {
toast(
<>
<TextView itemId='' rawText={'## Do you like this Map?'} />
<TextView rawText={'## Do you like this Map?'} />
<div>
<TextView
itemId=''
rawText={
'Support us building free opensource maps for communities and help us grow 🌱☀️'
}

View File

@ -24,7 +24,7 @@ export const ProfileTextView = ({
<div className='tw:my-10 tw:mt-2 tw:px-6'>
{shouldShowHeading && <h2 className='tw:text-lg tw:font-semibold'>{heading}</h2>}
<div className='tw:mt-2 tw:text-sm'>
<TextView item={item} text={text as string | null | undefined} itemId={item.id} />
<TextView item={item} text={text as string | null | undefined} />
</div>
</div>
)

View File

@ -15,14 +15,14 @@ export const OnepagerView = ({ item }: { item: Item }) => {
{item.user_created?.first_name && <ContactInfoView heading='Du hast Fragen?' item={item} />}
{/* Description Section */}
<div className='tw:my-10 tw:mt-2 tw:px-6 tw:text-sm '>
<TextView itemId={item.id} rawText={item.text ?? 'Keine Beschreibung vorhanden'} />
<TextView rawText={item.text ?? 'Keine Beschreibung vorhanden'} />
</div>
{/* Next Appointment Section */}
{item.next_appointment && (
<div className='tw:my-10 tw:px-6'>
<h2 className='tw:text-lg tw:font-semibold'>Nächste Termine</h2>
<div className='tw:mt-2 tw:text-sm'>
<TextView itemId={item.id} rawText={item.next_appointment} />
<TextView rawText={item.next_appointment} />
</div>
</div>
)}

View File

@ -5,7 +5,7 @@ import type { Item } from '#types/Item'
export const SimpleView = ({ item }: { item: Item }) => {
return (
<div className='tw:mt-8 tw:h-full tw:overflow-y-auto fade tw:px-6'>
<TextView text={item.text} itemId={item.id} />
<TextView text={item.text} />
</div>
)
}

View File

@ -143,7 +143,7 @@ export const TabsForm = ({
loading={loading}
/>
<div className='tw:overflow-y-auto tw:overflow-x-hidden tw:max-h-64 fade'>
<TextView truncate itemId={item.id} />
<TextView truncate />
</div>
</div>
))}

View File

@ -103,9 +103,9 @@ export const TabsView = ({
<StartEndView item={item}></StartEndView>
</div>
)}
<TextView text={item.text} itemId={item.id} />
<TextView text={item.text} />
<div className='tw:h-4'></div>
<TextView text={item.contact} itemId={item.id} />
<TextView text={item.contact} />
</div>
{item.layer?.itemType.questlog && (
<>
@ -296,7 +296,7 @@ export const TabsView = ({
loading={loading}
/>
<div className='tw:overflow-y-auto tw:overflow-x-hidden tw:max-h-64 fade'>
<TextView truncate text={i.text} itemId={item.id} />
<TextView truncate text={i.text} />
</div>
</div>
))}

View File

@ -82,7 +82,7 @@ export const ItemCard = ({
></HeaderView>
<div className='tw:overflow-y-auto tw:overflow-x-hidden tw:max-h-64 fade'>
{i.layer?.itemType.show_start_end && <StartEndView item={i}></StartEndView>}
{i.layer?.itemType.show_text && <TextView truncate text={i.text} itemId={i.id} />}
{i.layer?.itemType.show_text && <TextView truncate text={i.text} />}
</div>
<DateUserInfo item={i}></DateUserInfo>
</div>

View File

@ -0,0 +1,115 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { decodeTag } from '#utils/FormatTags'
import type { Tag } from '#types/Tag'
import type { NodeViewProps } from '@tiptap/react'
export interface HashtagOptions {
tags: Tag[]
onTagClick?: (tag: Tag) => void
HTMLAttributes: Record<string, unknown>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
hashtag: {
insertHashtag: (attributes: { label: string; id?: string }) => ReturnType
}
}
}
export const Hashtag = Node.create<HashtagOptions>({
name: 'hashtag',
group: 'inline',
inline: true,
atom: true,
addOptions() {
return {
tags: [],
onTagClick: undefined,
HTMLAttributes: {},
}
},
addAttributes() {
return {
id: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-id'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.id) return {}
return { 'data-id': attributes.id }
},
},
label: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-label'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.label) return {}
return { 'data-label': attributes.label }
},
},
}
},
parseHTML() {
return [{ tag: 'span[data-hashtag]' }]
},
renderHTML({ node, HTMLAttributes }) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-hashtag': '',
class: 'hashtag',
}),
`#${node.attrs.label as string}`,
]
},
addCommands() {
return {
insertHashtag:
(attributes) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: attributes,
})
},
}
},
addNodeView() {
return ReactNodeViewRenderer(HashtagComponent)
},
})
function HashtagComponent({ node, extension }: NodeViewProps) {
const options = extension.options as HashtagOptions
const tagName = node.attrs.label as string
const tag = options.tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase())
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (tag && options.onTagClick) {
options.onTagClick(tag)
}
}
return (
<NodeViewWrapper as='span' className='hashtag-wrapper'>
<span
className='hashtag'
style={tag ? { color: tag.color, cursor: 'pointer' } : { cursor: 'default' }}
onClick={handleClick}
>
{decodeTag(`#${tagName}`)}
</span>
</NodeViewWrapper>
)
}

View File

@ -0,0 +1,87 @@
import { mergeAttributes, Node } from '@tiptap/core'
export interface VideoEmbedOptions {
HTMLAttributes: Record<string, unknown>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoEmbed: {
setVideoEmbed: (options: { provider: 'youtube' | 'rumble'; videoId: string }) => ReturnType
}
}
}
export const VideoEmbed = Node.create<VideoEmbedOptions>({
name: 'videoEmbed',
group: 'block',
atom: true,
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
provider: {
default: 'youtube',
parseHTML: (element: HTMLElement) => element.getAttribute('provider'),
renderHTML: (attributes: Record<string, unknown>) => {
return { provider: attributes.provider }
},
},
videoId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('video-id'),
renderHTML: (attributes: Record<string, unknown>) => {
return { 'video-id': attributes.videoId }
},
},
}
},
parseHTML() {
return [{ tag: 'video-embed' }]
},
renderHTML({ node, HTMLAttributes }) {
const { provider, videoId } = node.attrs as { provider: string; videoId: string }
const src =
provider === 'youtube'
? `https://www.youtube-nocookie.com/embed/${videoId}`
: `https://rumble.com/embed/${videoId}`
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: 'video-embed-wrapper',
}),
[
'iframe',
{
src,
allowfullscreen: 'true',
allow: 'fullscreen; picture-in-picture',
class: 'video-embed',
frameborder: '0',
},
],
]
},
addCommands() {
return {
setVideoEmbed:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
})
},
}
},
})

View File

@ -0,0 +1,2 @@
export { Hashtag } from './Hashtag'
export { VideoEmbed } from './VideoEmbed'

View File

@ -0,0 +1,2 @@
export * from './extensions'
export * from './utils/preprocessMarkdown'

View File

@ -0,0 +1,123 @@
import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
/**
* Pre-processes markdown text before passing to TipTap.
* - Converts naked URLs to markdown links
* - Converts email addresses to mailto links
* - Converts video links (YouTube/Rumble) to video-embed HTML tags
* - Converts hashtags to hashtag HTML tags
*/
export function preprocessMarkdown(text: string): string {
if (!text) return ''
let result = text
// 1. Fix URLs (add https:// if missing)
result = fixUrls(result)
// 2. Convert naked URLs to markdown links
// Match URLs that are NOT already inside markdown link syntax
result = result.replace(
/(?<!\]?\()(?<!<)https?:\/\/[^\s)]+(?!\))(?!>)/g,
(url) => `[${url.replace(/https?:\/\/w{3}\./gi, '')}](${url})`,
)
// 3. Convert email addresses to mailto links
result = result.replace(mailRegex, (email) => `[${email}](mailto:${email})`)
// 4. Convert video links to video-embed tags
result = preprocessVideoLinks(result)
// 5. Convert hashtags to hashtag tags
result = preprocessHashtags(result)
return result
}
/**
* Converts YouTube and Rumble markdown links to video-embed HTML tags.
*/
export function preprocessVideoLinks(text: string): string {
let result = text
// YouTube: [Text](https://www.youtube.com/watch?v=VIDEO_ID)
result = result.replace(
/\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g,
'<video-embed provider="youtube" video-id="$2"></video-embed>',
)
// YouTube short URLs: [Text](https://youtu.be/VIDEO_ID)
result = result.replace(
/\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g,
'<video-embed provider="youtube" video-id="$2"></video-embed>',
)
// Rumble embed URLs: [Text](https://rumble.com/embed/VIDEO_ID)
result = result.replace(
/\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g,
'<video-embed provider="rumble" video-id="$2"></video-embed>',
)
return result
}
/**
* Converts #hashtag syntax to span tags that TipTap can parse.
* Only converts hashtags that are NOT already inside markdown links.
*/
export function preprocessHashtags(text: string): string {
// Don't convert hashtags that are already inside link syntax [#tag](#tag)
// Use a negative lookbehind for [ and (
return text.replace(
/(?<!\[)(?<!\()#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)(?!\]|\))/g,
'<span data-hashtag data-label="$1">#$1</span>',
)
}
/**
* Removes markdown syntax for plain text display (used for truncation calculation).
*/
export function removeMarkdownSyntax(text: string): string {
return text
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text
.replace(/<[^>]+>/g, '') // Remove HTML tags
}
/**
* Truncates text to a character limit, respecting paragraph boundaries.
*/
export function truncateMarkdown(text: string, limit: number): string {
const plainText = removeMarkdownSyntax(text)
if (plainText.length <= limit) {
return text
}
let truncated = ''
let length = 0
const paragraphs = text.split('\n')
for (const paragraph of paragraphs) {
const plainParagraph = removeMarkdownSyntax(paragraph)
if (length + plainParagraph.length > limit) {
// Calculate how many chars we can take from this paragraph
const remaining = limit - length
if (remaining > 0) {
truncated += paragraph.slice(0, remaining) + '...'
}
break
} else {
truncated += paragraph + '\n'
length += plainParagraph.length
}
}
return truncated.trim()
}