mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-02-06 09:55:47 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
b7f5b0092f
commit
319f4a5057
@ -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<HTMLDivElement>(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(
|
||||
/(?<!\]?\()(?<!<)https?:\/\/[^\s)]+(?!\))(?!>)/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 (
|
||||
<a
|
||||
className='hashtag'
|
||||
style={
|
||||
tag && {
|
||||
color: tag.color,
|
||||
}
|
||||
}
|
||||
key={tag ? tag.name + itemId : itemId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
addFilterTag(tag)
|
||||
}}
|
||||
>
|
||||
{decodeTag(children)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
// 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 (
|
||||
<iframe src={youtubeEmbedUrl} allow='fullscreen; picture-in-picture' allowFullScreen />
|
||||
)
|
||||
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")
|
||||
}
|
||||
|
||||
// Rumble
|
||||
if (href.startsWith('https://rumble.com/embed/')) {
|
||||
return <iframe src={href} allow='fullscreen; picture-in-picture' allowFullScreen />
|
||||
container.addEventListener('click', handleClick)
|
||||
return () => {
|
||||
container.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
// HashTag
|
||||
if (href.startsWith('#')) {
|
||||
const tag = tags.find((t) => t.name.toLowerCase() === decodeURI(href).slice(1).toLowerCase())
|
||||
if (tag)
|
||||
return (
|
||||
<HashTag tag={tag} itemId={itemId}>
|
||||
{children}
|
||||
</HashTag>
|
||||
)
|
||||
else return children
|
||||
}
|
||||
|
||||
// Internal Link (React Router)
|
||||
if (href.startsWith('/')) {
|
||||
return <RouterLink to={href}>{children}</RouterLink>
|
||||
}
|
||||
|
||||
// External Link
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noreferrer'>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
if (!innerText) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div translate='no'>
|
||||
<Markdown
|
||||
className={'markdown tw:text-map tw:leading-map tw:text-sm'}
|
||||
remarkPlugins={[remarkBreaks]}
|
||||
components={{
|
||||
a: Link,
|
||||
}}
|
||||
>
|
||||
{replacedText}
|
||||
</Markdown>
|
||||
<div translate='no' ref={containerRef}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function removeMarkdownKeepLinksAndParagraphs(text) {
|
||||
// Remove Markdown syntax using regular expressions but keep links and paragraphs
|
||||
return text
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
|
||||
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
|
||||
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
|
||||
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
|
||||
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
|
||||
.replace(/^\s*\n/gm, '\n') // Preserve empty lines
|
||||
.replace(/(\r\n|\n|\r)/gm, '\n') // Preserve line breaks
|
||||
}
|
||||
|
||||
function truncateText(text, limit) {
|
||||
if (text.length <= limit) {
|
||||
return text
|
||||
}
|
||||
|
||||
let truncated = ''
|
||||
let length = 0
|
||||
|
||||
// Split the text by paragraphs
|
||||
const paragraphs = text.split('\n')
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (length + paragraph.length > limit) {
|
||||
truncated += paragraph.slice(0, limit - length) + '...'
|
||||
break
|
||||
} else {
|
||||
truncated += paragraph + '\n'
|
||||
length += paragraph.length
|
||||
}
|
||||
}
|
||||
|
||||
return truncated.trim()
|
||||
}
|
||||
|
||||
@ -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} itemId={props.item.id} />}
|
||||
{props.children ?? <TextView text={props.item.text} />}
|
||||
</div>
|
||||
<div className='tw:flex tw:-mb-1 tw:flex-row tw:mr-2 tw:mt-1'>
|
||||
{infoExpanded ? (
|
||||
|
||||
@ -122,10 +122,9 @@ export function UtopiaMapInner({
|
||||
setTimeout(() => {
|
||||
toast(
|
||||
<>
|
||||
<TextView itemId='' rawText={'## Do you like this Map?'} />
|
||||
<TextView rawText={'## Do you like this Map?'} />
|
||||
<div>
|
||||
<TextView
|
||||
itemId=''
|
||||
rawText={
|
||||
'Support us building free opensource maps for communities and help us grow 🌱☀️'
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ export const ProfileTextView = ({
|
||||
<div className='tw:my-10 tw:mt-2 tw:px-6'>
|
||||
{shouldShowHeading && <h2 className='tw:text-lg tw:font-semibold'>{heading}</h2>}
|
||||
<div className='tw:mt-2 tw:text-sm'>
|
||||
<TextView item={item} text={text as string | null | undefined} itemId={item.id} />
|
||||
<TextView item={item} text={text as string | null | undefined} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -15,14 +15,14 @@ export const OnepagerView = ({ item }: { item: Item }) => {
|
||||
{item.user_created?.first_name && <ContactInfoView heading='Du hast Fragen?' item={item} />}
|
||||
{/* Description Section */}
|
||||
<div className='tw:my-10 tw:mt-2 tw:px-6 tw:text-sm '>
|
||||
<TextView itemId={item.id} rawText={item.text ?? 'Keine Beschreibung vorhanden'} />
|
||||
<TextView rawText={item.text ?? 'Keine Beschreibung vorhanden'} />
|
||||
</div>
|
||||
{/* Next Appointment Section */}
|
||||
{item.next_appointment && (
|
||||
<div className='tw:my-10 tw:px-6'>
|
||||
<h2 className='tw:text-lg tw:font-semibold'>Nächste Termine</h2>
|
||||
<div className='tw:mt-2 tw:text-sm'>
|
||||
<TextView itemId={item.id} rawText={item.next_appointment} />
|
||||
<TextView rawText={item.next_appointment} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -5,7 +5,7 @@ import type { Item } from '#types/Item'
|
||||
export const SimpleView = ({ item }: { item: Item }) => {
|
||||
return (
|
||||
<div className='tw:mt-8 tw:h-full tw:overflow-y-auto fade tw:px-6'>
|
||||
<TextView text={item.text} itemId={item.id} />
|
||||
<TextView text={item.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -143,7 +143,7 @@ export const TabsForm = ({
|
||||
loading={loading}
|
||||
/>
|
||||
<div className='tw:overflow-y-auto tw:overflow-x-hidden tw:max-h-64 fade'>
|
||||
<TextView truncate itemId={item.id} />
|
||||
<TextView truncate />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -103,9 +103,9 @@ export const TabsView = ({
|
||||
<StartEndView item={item}></StartEndView>
|
||||
</div>
|
||||
)}
|
||||
<TextView text={item.text} itemId={item.id} />
|
||||
<TextView text={item.text} />
|
||||
<div className='tw:h-4'></div>
|
||||
<TextView text={item.contact} itemId={item.id} />
|
||||
<TextView text={item.contact} />
|
||||
</div>
|
||||
{item.layer?.itemType.questlog && (
|
||||
<>
|
||||
@ -296,7 +296,7 @@ export const TabsView = ({
|
||||
loading={loading}
|
||||
/>
|
||||
<div className='tw:overflow-y-auto tw:overflow-x-hidden tw:max-h-64 fade'>
|
||||
<TextView truncate text={i.text} itemId={item.id} />
|
||||
<TextView truncate text={i.text} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -82,7 +82,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} itemId={i.id} />}
|
||||
{i.layer?.itemType.show_text && <TextView truncate text={i.text} />}
|
||||
</div>
|
||||
<DateUserInfo item={i}></DateUserInfo>
|
||||
</div>
|
||||
|
||||
115
lib/src/Components/TipTap/extensions/Hashtag.tsx
Normal file
115
lib/src/Components/TipTap/extensions/Hashtag.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
|
||||
import { decodeTag } from '#utils/FormatTags'
|
||||
|
||||
import type { Tag } from '#types/Tag'
|
||||
import type { NodeViewProps } from '@tiptap/react'
|
||||
|
||||
export interface HashtagOptions {
|
||||
tags: Tag[]
|
||||
onTagClick?: (tag: Tag) => void
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
hashtag: {
|
||||
insertHashtag: (attributes: { label: string; id?: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Hashtag = Node.create<HashtagOptions>({
|
||||
name: 'hashtag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
tags: [],
|
||||
onTagClick: undefined,
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-id'),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
if (!attributes.id) return {}
|
||||
return { 'data-id': attributes.id }
|
||||
},
|
||||
},
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-label'),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
if (!attributes.label) return {}
|
||||
return { 'data-label': attributes.label }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-hashtag]' }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-hashtag': '',
|
||||
class: 'hashtag',
|
||||
}),
|
||||
`#${node.attrs.label as string}`,
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertHashtag:
|
||||
(attributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(HashtagComponent)
|
||||
},
|
||||
})
|
||||
|
||||
function HashtagComponent({ node, extension }: NodeViewProps) {
|
||||
const options = extension.options as HashtagOptions
|
||||
const tagName = node.attrs.label as string
|
||||
const tag = options.tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase())
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (tag && options.onTagClick) {
|
||||
options.onTagClick(tag)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as='span' className='hashtag-wrapper'>
|
||||
<span
|
||||
className='hashtag'
|
||||
style={tag ? { color: tag.color, cursor: 'pointer' } : { cursor: 'default' }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{decodeTag(`#${tagName}`)}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
87
lib/src/Components/TipTap/extensions/VideoEmbed.tsx
Normal file
87
lib/src/Components/TipTap/extensions/VideoEmbed.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
|
||||
export interface VideoEmbedOptions {
|
||||
HTMLAttributes: Record<string, unknown>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
videoEmbed: {
|
||||
setVideoEmbed: (options: { provider: 'youtube' | 'rumble'; videoId: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoEmbed = Node.create<VideoEmbedOptions>({
|
||||
name: 'videoEmbed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
provider: {
|
||||
default: 'youtube',
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('provider'),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
return { provider: attributes.provider }
|
||||
},
|
||||
},
|
||||
videoId: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('video-id'),
|
||||
renderHTML: (attributes: Record<string, unknown>) => {
|
||||
return { 'video-id': attributes.videoId }
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'video-embed' }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { provider, videoId } = node.attrs as { provider: string; videoId: string }
|
||||
|
||||
const src =
|
||||
provider === 'youtube'
|
||||
? `https://www.youtube-nocookie.com/embed/${videoId}`
|
||||
: `https://rumble.com/embed/${videoId}`
|
||||
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
class: 'video-embed-wrapper',
|
||||
}),
|
||||
[
|
||||
'iframe',
|
||||
{
|
||||
src,
|
||||
allowfullscreen: 'true',
|
||||
allow: 'fullscreen; picture-in-picture',
|
||||
class: 'video-embed',
|
||||
frameborder: '0',
|
||||
},
|
||||
],
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setVideoEmbed:
|
||||
(options) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
2
lib/src/Components/TipTap/extensions/index.ts
Normal file
2
lib/src/Components/TipTap/extensions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Hashtag } from './Hashtag'
|
||||
export { VideoEmbed } from './VideoEmbed'
|
||||
2
lib/src/Components/TipTap/index.ts
Normal file
2
lib/src/Components/TipTap/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './extensions'
|
||||
export * from './utils/preprocessMarkdown'
|
||||
123
lib/src/Components/TipTap/utils/preprocessMarkdown.ts
Normal file
123
lib/src/Components/TipTap/utils/preprocessMarkdown.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { fixUrls, mailRegex } from '#utils/ReplaceURLs'
|
||||
|
||||
/**
|
||||
* Pre-processes markdown text before passing to TipTap.
|
||||
* - Converts naked URLs to markdown links
|
||||
* - Converts email addresses to mailto links
|
||||
* - Converts video links (YouTube/Rumble) to video-embed HTML tags
|
||||
* - Converts hashtags to hashtag HTML tags
|
||||
*/
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
let result = text
|
||||
|
||||
// 1. Fix URLs (add https:// if missing)
|
||||
result = fixUrls(result)
|
||||
|
||||
// 2. Convert naked URLs to markdown links
|
||||
// Match URLs that are NOT already inside markdown link syntax
|
||||
result = result.replace(
|
||||
/(?<!\]?\()(?<!<)https?:\/\/[^\s)]+(?!\))(?!>)/g,
|
||||
(url) => `[${url.replace(/https?:\/\/w{3}\./gi, '')}](${url})`,
|
||||
)
|
||||
|
||||
// 3. Convert email addresses to mailto links
|
||||
result = result.replace(mailRegex, (email) => `[${email}](mailto:${email})`)
|
||||
|
||||
// 4. Convert video links to video-embed tags
|
||||
result = preprocessVideoLinks(result)
|
||||
|
||||
// 5. Convert hashtags to hashtag tags
|
||||
result = preprocessHashtags(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts YouTube and Rumble markdown links to video-embed HTML tags.
|
||||
*/
|
||||
export function preprocessVideoLinks(text: string): string {
|
||||
let result = text
|
||||
|
||||
// YouTube: [Text](https://www.youtube.com/watch?v=VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^)&]+)[^)]*\)/g,
|
||||
'<video-embed provider="youtube" video-id="$2"></video-embed>',
|
||||
)
|
||||
|
||||
// YouTube short URLs: [Text](https://youtu.be/VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/youtu\.be\/([^?)]+)[^)]*\)/g,
|
||||
'<video-embed provider="youtube" video-id="$2"></video-embed>',
|
||||
)
|
||||
|
||||
// Rumble embed URLs: [Text](https://rumble.com/embed/VIDEO_ID)
|
||||
result = result.replace(
|
||||
/\[([^\]]*)\]\(https?:\/\/rumble\.com\/embed\/([^)]+)\)/g,
|
||||
'<video-embed provider="rumble" video-id="$2"></video-embed>',
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts #hashtag syntax to span tags that TipTap can parse.
|
||||
* Only converts hashtags that are NOT already inside markdown links.
|
||||
*/
|
||||
export function preprocessHashtags(text: string): string {
|
||||
// Don't convert hashtags that are already inside link syntax [#tag](#tag)
|
||||
// Use a negative lookbehind for [ and (
|
||||
return text.replace(
|
||||
/(?<!\[)(?<!\()#([a-zA-Z0-9À-ÖØ-öø-ʸ_-]+)(?!\]|\))/g,
|
||||
'<span data-hashtag data-label="$1">#$1</span>',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes markdown syntax for plain text display (used for truncation calculation).
|
||||
*/
|
||||
export function removeMarkdownSyntax(text: string): string {
|
||||
return text
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
|
||||
.replace(/(`{1,3})(.*?)\1/g, '$2') // Remove inline code
|
||||
.replace(/(\*{1,2}|_{1,2})(.*?)\1/g, '$2') // Remove bold and italic
|
||||
.replace(/(#+)\s+(.*)/g, '$2') // Remove headers
|
||||
.replace(/>\s+(.*)/g, '$1') // Remove blockquotes
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text
|
||||
.replace(/<[^>]+>/g, '') // Remove HTML tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates text to a character limit, respecting paragraph boundaries.
|
||||
*/
|
||||
export function truncateMarkdown(text: string, limit: number): string {
|
||||
const plainText = removeMarkdownSyntax(text)
|
||||
|
||||
if (plainText.length <= limit) {
|
||||
return text
|
||||
}
|
||||
|
||||
let truncated = ''
|
||||
let length = 0
|
||||
|
||||
const paragraphs = text.split('\n')
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const plainParagraph = removeMarkdownSyntax(paragraph)
|
||||
|
||||
if (length + plainParagraph.length > limit) {
|
||||
// Calculate how many chars we can take from this paragraph
|
||||
const remaining = limit - length
|
||||
if (remaining > 0) {
|
||||
truncated += paragraph.slice(0, remaining) + '...'
|
||||
}
|
||||
break
|
||||
} else {
|
||||
truncated += paragraph + '\n'
|
||||
length += plainParagraph.length
|
||||
}
|
||||
}
|
||||
|
||||
return truncated.trim()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user