From 7e1d95ecdd1e940fba73ddd70f4c5500cddcf339 Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Wed, 14 Jan 2026 10:32:07 +0100 Subject: [PATCH] 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 --- .../ItemPopupComponents/TextViewStatic.tsx | 128 ++++++++++++++++++ .../Map/Subcomponents/ItemViewPopup.tsx | 4 +- lib/src/Components/Templates/ItemCard.tsx | 5 +- .../TipTap/utils/preprocessMarkdown.ts | 25 ++++ .../TipTap/utils/simpleMarkdownToHtml.tsx | 97 +++++++++++++ 5 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx create mode 100644 lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx new file mode 100644 index 00000000..db02c64a --- /dev/null +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextViewStatic.tsx @@ -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(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 ( +
+ ) +} diff --git a/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx b/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx index 10f9f30f..13b5a5f7 100644 --- a/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemViewPopup.tsx @@ -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} />
- {props.children ?? } + {props.children ?? }
{infoExpanded ? ( diff --git a/lib/src/Components/Templates/ItemCard.tsx b/lib/src/Components/Templates/ItemCard.tsx index 44b222aa..460bcb27 100644 --- a/lib/src/Components/Templates/ItemCard.tsx +++ b/lib/src/Components/Templates/ItemCard.tsx @@ -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 = ({ >
{i.layer?.itemType.show_start_end && } - {i.layer?.itemType.show_text && } + {i.layer?.itemType.show_text && }
diff --git a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts index faec2a1a..e790ae2e 100644 --- a/lib/src/Components/TipTap/utils/preprocessMarkdown.ts +++ b/lib/src/Components/TipTap/utils/preprocessMarkdown.ts @@ -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 diff --git a/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx new file mode 100644 index 00000000..00ce7a55 --- /dev/null +++ b/lib/src/Components/TipTap/utils/simpleMarkdownToHtml.tsx @@ -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, '>') + + // Restore our preprocessed tags + html = html + .replace(/<video-embed/g, '') + .replace(/<span data-hashtag/g, '') + .replace(/></g, '><') + .replace(/">/g, '">') + + // Convert video-embed tags to iframes + html = html.replace( + /<\/video-embed>/g, + (_, provider: string, videoId: string) => { + const src = + provider === 'youtube' + ? `https://www.youtube-nocookie.com/embed/${videoId}` + : `https://rumble.com/embed/${videoId}` + return `
` + }, + ) + + // Convert hashtag spans to styled spans with tag colors + html = html.replace( + /#([^<]+)<\/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 `${decoded}` + }, + ) + + // Bold: **text** or __text__ + html = html.replace(/(\*\*|__)(.*?)\1/g, '$2') + + // Italic: *text* or _text_ (but not inside words) + html = html.replace(/(?$2') + + // Inline code: `code` + html = html.replace(/`([^`]+)`/g, '$1') + + // 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 `${linkText}` + }, + ) + + // Headers: # text, ## text, etc. + html = html.replace(/^(#{1,6})\s+(.*)$/gm, (_, hashes: string, content: string) => { + const level = hashes.length + return `${content}` + }) + + // Blockquotes: > text + html = html.replace(/^>\s+(.*)$/gm, '
$1
') + + // Line breaks: convert newlines to
or

+ // Double newline = paragraph break + html = html.replace(/\n\n+/g, '

') + // Single newline = line break + html = html.replace(/\n/g, '
') + + // Wrap in paragraph if not already + if (!html.startsWith('')) { + html = `

${html}

` + } + + // Clean up empty paragraphs + html = html.replace(/

<\/p>/g, '') + html = html.replace(/

(
)+<\/p>/g, '') + + return html +}