From 319f4a5057b7e6f800c7c2e32db6bcce89a21839 Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Wed, 14 Jan 2026 09:58:43 +0100 Subject: [PATCH] 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 --- .../ItemPopupComponents/TextView.tsx | 229 +++++++----------- .../Map/Subcomponents/ItemViewPopup.tsx | 2 +- lib/src/Components/Map/UtopiaMapInner.tsx | 3 +- .../Profile/Subcomponents/ProfileTextView.tsx | 2 +- .../Profile/Templates/OnepagerView.tsx | 4 +- .../Profile/Templates/SimpleView.tsx | 2 +- .../Components/Profile/Templates/TabsForm.tsx | 2 +- .../Components/Profile/Templates/TabsView.tsx | 6 +- lib/src/Components/Templates/ItemCard.tsx | 2 +- .../Components/TipTap/extensions/Hashtag.tsx | 115 +++++++++ .../TipTap/extensions/VideoEmbed.tsx | 87 +++++++ lib/src/Components/TipTap/extensions/index.ts | 2 + lib/src/Components/TipTap/index.ts | 2 + .../TipTap/utils/preprocessMarkdown.ts | 123 ++++++++++ 14 files changed, 429 insertions(+), 152 deletions(-) create mode 100644 lib/src/Components/TipTap/extensions/Hashtag.tsx create mode 100644 lib/src/Components/TipTap/extensions/VideoEmbed.tsx create mode 100644 lib/src/Components/TipTap/extensions/index.ts create mode 100644 lib/src/Components/TipTap/index.ts create mode 100644 lib/src/Components/TipTap/utils/preprocessMarkdown.ts diff --git a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx index 67968ff2..7fcc58bb 100644 --- a/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx +++ b/lib/src/Components/Map/Subcomponents/ItemPopupComponents/TextView.tsx @@ -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(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( - /(?)/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 ( - { - e.stopPropagation() - addFilterTag(tag) - }} - > - {decodeTag(children)} - - ) - } + // 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 ( -