mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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>
145 lines
3.6 KiB
TypeScript
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>
|
|
)
|
|
}
|