mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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:
parent
319f4a5057
commit
7e1d95ecdd
@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
97
lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx
Normal file
97
lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Restore our preprocessed tags
|
||||
html = html
|
||||
.replace(/<video-embed/g, '<video-embed')
|
||||
.replace(/<\/video-embed>/g, '</video-embed>')
|
||||
.replace(/<span data-hashtag/g, '<span data-hashtag')
|
||||
.replace(/<\/span>/g, '</span>')
|
||||
.replace(/></g, '><')
|
||||
.replace(/">/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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user