feat(TextViewStatic): add lightweight static renderer for popups

Add TextViewStatic component for Leaflet popups and card previews:
- Uses simple HTML rendering instead of TipTap EditorContent
- No contenteditable attribute = no Leaflet click blocking issues
- Better performance for rendering many items

Changes:
- Add TextViewStatic.tsx for popups/cards
- Add simpleMarkdownToHtml.tsx utility for lightweight markdown conversion
- Update ItemViewPopup to use TextViewStatic
- Update ItemCard to use TextViewStatic

TextView (full TipTap) remains for profile pages with all features.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Tranelis 2026-01-14 10:32:07 +01:00
parent 319f4a5057
commit 7e1d95ecdd
5 changed files with 255 additions and 4 deletions

View File

@ -0,0 +1,128 @@
import { useRef, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAddFilterTag } from '#components/Map/hooks/useFilter'
import { useTags } from '#components/Map/hooks/useTags'
import {
preprocessMarkdown,
removeMarkdownSyntax,
truncateMarkdown,
} from '#components/TipTap/utils/preprocessMarkdown'
import { simpleMarkdownToHtml } from '#components/TipTap/utils/simpleMarkdownToHtml'
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
/**
* Lightweight static text renderer for popups and card previews.
* Uses simple HTML rendering instead of TipTap for better performance
* and compatibility with Leaflet popups (no contenteditable issues).
*
* @category Map
*/
export const TextViewStatic = ({
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 and convert to HTML
const html = useMemo(() => {
if (!innerText) return ''
const processed = preprocessMarkdown(innerText)
return simpleMarkdownToHtml(processed, tags)
}, [innerText, tags])
// Handle clicks for internal navigation and hashtags
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
// Handle hashtag clicks
const hashtag = target.closest('[data-hashtag]')
if (hashtag) {
e.preventDefault()
e.stopPropagation()
const label = hashtag.getAttribute('data-label')
if (label) {
const tag = tags.find((t: Tag) => t.name.toLowerCase() === label.toLowerCase())
if (tag) {
addFilterTag(tag)
}
}
return
}
// Handle link clicks
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 target="_blank"
}
container.addEventListener('click', handleClick)
return () => {
container.removeEventListener('click', handleClick)
}
}, [navigate, tags, addFilterTag])
if (!innerText) {
return null
}
return (
<div
translate='no'
ref={containerRef}
className='markdown tw:text-map tw:leading-map tw:text-sm'
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}

View File

@ -19,7 +19,7 @@ import { timeAgo } from '#utils/TimeAgo'
import { removeItemFromUrl } from '#utils/UrlHelper'
import { HeaderView } from './ItemPopupComponents/HeaderView'
import { TextView } from './ItemPopupComponents/TextView'
import { TextViewStatic } from './ItemPopupComponents/TextViewStatic'
import type { Item } from '#types/Item'
@ -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} />}
{props.children ?? <TextViewStatic text={props.item.text} />}
</div>
<div className='tw:flex tw:-mb-1 tw:flex-row tw:mr-2 tw:mt-1'>
{infoExpanded ? (

View File

@ -5,7 +5,8 @@ import { useNavigate } from 'react-router-dom'
import { usePopupForm } from '#components/Map/hooks/usePopupForm'
import { useSetSelectPosition } from '#components/Map/hooks/useSelectPosition'
import useWindowDimensions from '#components/Map/hooks/useWindowDimension'
import { StartEndView, TextView } from '#components/Map/Subcomponents/ItemPopupComponents'
import { StartEndView } from '#components/Map/Subcomponents/ItemPopupComponents'
import { TextViewStatic } from '#components/Map/Subcomponents/ItemPopupComponents/TextViewStatic'
import { HeaderView } from '#components/Map/Subcomponents/ItemPopupComponents/HeaderView'
import { DateUserInfo } from './DateUserInfo'
@ -82,7 +83,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} />}
{i.layer?.itemType.show_text && <TextViewStatic truncate text={i.text} />}
</div>
<DateUserInfo item={i}></DateUserInfo>
</div>

View File

@ -1,5 +1,30 @@
import { Editor } from '@tiptap/core'
import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
import type { JSONContent, Extensions } from '@tiptap/core'
/**
* Converts pre-processed markdown/HTML to TipTap JSON format.
* Creates a temporary editor instance to parse the content.
*/
export function markdownToTiptapJson(
content: string,
extensions: Extensions,
): JSONContent {
// Create a temporary editor to parse HTML/markdown
const editor = new Editor({
extensions,
content,
// We immediately destroy this, so no need for DOM attachment
})
const json = editor.getJSON()
editor.destroy()
return json
}
/**
* Pre-processes markdown text before passing to TipTap.
* - Converts naked URLs to markdown links

View File

@ -0,0 +1,97 @@
import { decodeTag } from '#utils/FormatTags'
import type { Tag } from '#types/Tag'
/**
* Simple markdown to HTML converter for static rendering.
* Handles basic markdown syntax without requiring TipTap.
* Used for lightweight popup/card previews.
*/
export function simpleMarkdownToHtml(text: string, tags: Tag[]): string {
if (!text) return ''
let html = text
// Escape HTML first (but preserve our preprocessed tags)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Restore our preprocessed tags
html = html
.replace(/&lt;video-embed/g, '<video-embed')
.replace(/&lt;\/video-embed&gt;/g, '</video-embed>')
.replace(/&lt;span data-hashtag/g, '<span data-hashtag')
.replace(/&lt;\/span&gt;/g, '</span>')
.replace(/&gt;&lt;/g, '><')
.replace(/"&gt;/g, '">')
// Convert video-embed tags to iframes
html = html.replace(
/<video-embed provider="(youtube|rumble)" video-id="([^"]+)"><\/video-embed>/g,
(_, provider: string, videoId: string) => {
const src =
provider === 'youtube'
? `https://www.youtube-nocookie.com/embed/${videoId}`
: `https://rumble.com/embed/${videoId}`
return `<div class="video-embed-wrapper"><iframe src="${src}" allowfullscreen allow="fullscreen; picture-in-picture" class="video-embed"></iframe></div>`
},
)
// Convert hashtag spans to styled spans with tag colors
html = html.replace(
/<span data-hashtag data-label="([^"]+)">#([^<]+)<\/span>/g,
(_, label: string, tagText: string) => {
const tag = tags.find((t) => t.name.toLowerCase() === label.toLowerCase())
const color = tag?.color ?? 'inherit'
const decoded = decodeTag(`#${tagText}`)
return `<span data-hashtag data-label="${label}" class="hashtag" style="color: ${color}; cursor: pointer;">${decoded}</span>`
},
)
// Bold: **text** or __text__
html = html.replace(/(\*\*|__)(.*?)\1/g, '<strong>$2</strong>')
// Italic: *text* or _text_ (but not inside words)
html = html.replace(/(?<!\w)(\*|_)(?!\s)(.*?)(?<!\s)\1(?!\w)/g, '<em>$2</em>')
// Inline code: `code`
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links: [text](url)
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, linkText: string, url: string) => {
const isExternal = url.startsWith('http')
const attrs = isExternal ? 'target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${url}" ${attrs}>${linkText}</a>`
},
)
// Headers: # text, ## text, etc.
html = html.replace(/^(#{1,6})\s+(.*)$/gm, (_, hashes: string, content: string) => {
const level = hashes.length
return `<h${level}>${content}</h${level}>`
})
// Blockquotes: > text
html = html.replace(/^>\s+(.*)$/gm, '<blockquote>$1</blockquote>')
// Line breaks: convert newlines to <br> or <p>
// Double newline = paragraph break
html = html.replace(/\n\n+/g, '</p><p>')
// Single newline = line break
html = html.replace(/\n/g, '<br>')
// Wrap in paragraph if not already
if (!html.startsWith('<h') && !html.startsWith('<p>')) {
html = `<p>${html}</p>`
}
// Clean up empty paragraphs
html = html.replace(/<p><\/p>/g, '')
html = html.replace(/<p>(<br>)+<\/p>/g, '')
return html
}