diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx index 7a962f3f..a74b40d1 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -176,8 +176,31 @@ export function AdvancedEventLabMarkupToolbar({ ) })} - run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}> - {t('Advanced lab tb horizontalRule')} + + run((v) => + labInsertRaw( + v, + sliceRef, + `\n${t('Advanced lab tb heading placeholder')}\n===\n` + ) + ) + } + > + {t('Advanced lab tb setextH1')} + + + run((v) => + labInsertRaw( + v, + sliceRef, + `\n${t('Advanced lab tb heading placeholder')}\n---\n` + ) + ) + } + > + {t('Advanced lab tb setextH2')} @@ -197,6 +220,12 @@ export function AdvancedEventLabMarkupToolbar({ run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}> {t('Advanced lab tb italic')} + run((v) => labWrapOrSnippet(v, sliceRef, '__', 'bold'))}> + {t('Advanced lab tb boldUnderscore')} + + run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}> + {t('Advanced lab tb italicUnderscore')} + run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}> {t('Advanced lab tb strike')} @@ -224,6 +253,44 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb image')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '[', + 'link text', + '](https://example.com "Link title")' + ) + ) + } + > + + {t('Advanced lab tb linkTitled')} + + + run((v) => + labInsertSnippet( + v, + sliceRef, + '![', + 'alt text', + '](https://example.com/image.png "Image title")' + ) + ) + } + > + + {t('Advanced lab tb imageTitled')} + + + run((v) => labInsertRaw(v, sliceRef, ' \n'))}> + + {t('Advanced lab tb hardBreak')} + @@ -240,10 +307,28 @@ export function AdvancedEventLabMarkupToolbar({ {t('Advanced lab tb bulletList')} + run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}> + + {t('Advanced lab tb bulletListStar')} + run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}> {t('Advanced lab tb orderedList')} + + run((v) => + labInsertRaw( + v, + sliceRef, + '\n4. item starting at four\n5. next item\n' + ) + ) + } + > + + {t('Advanced lab tb orderedListStart')} + run((v) => @@ -454,16 +539,32 @@ export function AdvancedEventLabMarkupToolbar({ - + + + + + + {t('Advanced lab tb horizontalRules')} + run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}> + {t('Advanced lab tb hrDashes')} + + run((v) => labInsertRaw(v, sliceRef, '\n***\n'))}> + {t('Advanced lab tb hrAsterisks')} + + run((v) => labInsertRaw(v, sliceRef, '\n___\n'))}> + {t('Advanced lab tb hrUnderscores')} + + + ) } diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 85d562a0..85fc3f34 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -50,6 +50,8 @@ export default function Image({ holdUntilClick = false, fetchPriority, onClick, + /** Native tooltip on hover (e.g. Markdown `![alt](url "title")`). When set, overrides alt-as-title on ``. */ + tooltipTitle, ...props }: HTMLAttributes & { classNames?: { @@ -58,6 +60,8 @@ export default function Image({ } image: TImetaInfo alt?: string + /** Shown as the `` tooltip when non-empty. */ + tooltipTitle?: string hideIfError?: boolean errorPlaceholder?: React.ReactNode /** Passed to the inner `` (e.g. profile banner vs avatar load order). */ @@ -93,6 +97,10 @@ export default function Image({ const loadSettledRef = useRef(false) const finalAlt = imetaAlt || alt + const imgTitle = + tooltipTitle != null && String(tooltipTitle).trim() !== '' + ? String(tooltipTitle).trim() + : finalAlt || undefined const openLinkHref = (isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || '' @@ -216,9 +224,15 @@ export default function Image({ onClick?.(e) } + const titled = tooltipTitle != null && String(tooltipTitle).trim() !== '' + return ( { + const raw = token.title + if (raw == null) return undefined + const s = String(raw).trim() + return s.length > 0 ? s : undefined + } + const renderInlineTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { const out: React.ReactNode[] = [] for (let i = 0; i < tokens.length; i++) { @@ -3294,6 +3293,11 @@ function parseMarkdownContentMarked( } case 'link': { const href = String(token.href ?? '') + const linkTip = markdownTokenTitle(token) + const linkVisual = cn( + 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words', + linkTip && 'cursor-help underline decoration-dotted decoration-current/70 underline-offset-2' + ) const children = stripNestedAnchorsFromNodes( renderInlineTokens(token.tokens ?? [{ type: 'text', text: token.text ?? href }], `${key}-link`), `${key}-link-sanitized` @@ -3303,7 +3307,8 @@ function parseMarkdownContentMarked( {children} @@ -3315,7 +3320,8 @@ function parseMarkdownContentMarked( href={href} target="_blank" rel="noopener noreferrer" - className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" + title={linkTip} + className={linkVisual} > {children} @@ -3331,6 +3337,7 @@ function parseMarkdownContentMarked( const cleaned = cleanUrl(src) if (!cleaned) break const label = String(token.text ?? '') + const imageTip = markdownTokenTitle(token) if (isVideo(cleaned) || isAudio(cleaned) || isHlsPlaylistUrl(cleaned)) { const poster = videoPosterMap?.get(cleaned) out.push( @@ -3366,6 +3373,7 @@ function parseMarkdownContentMarked( key={`${key}-img-inline`} image={{ ...baseImeta, url: src }} alt={label || 'image'} + tooltipTitle={imageTip} className="w-full rounded-lg cursor-zoom-in" classNames={{ wrapper: 'not-prose my-2 block max-w-[400px] mx-auto rounded-lg w-full', @@ -4115,6 +4123,7 @@ function parseMarkdownContentMarked( key={`${key}-img-block`} image={imetaInfoForStandaloneImageUrl(cleaned)} alt={imageToken.text || 'image'} + tooltipTitle={markdownTokenTitle(imageToken)} className="w-full rounded-lg cursor-zoom-in my-0" classNames={{ wrapper: 'my-2 block max-w-[400px] mx-auto' }} holdUntilClick={lazyMedia} @@ -4133,12 +4142,31 @@ function parseMarkdownContentMarked( const renderBlockTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { const nodes: React.ReactNode[] = [] + /** Marked emits `space` for inter-block newlines; ≥2 are required between blocks—extras are user intent. */ + const spaceTokenExtraGapEm = (t: { raw?: string }): number => { + const raw = String(t.raw ?? '') + const newlineCount = raw.match(/\n/g)?.length ?? 0 + const extraLines = Math.max(0, newlineCount - 2) + return Math.min(extraLines, 12) * 1.25 + } for (let i = 0; i < tokens.length; i++) { const token = tokens[i] const key = `${keyPrefix}-${i}` switch (token.type) { - case 'space': + case 'space': { + const gapEm = spaceTokenExtraGapEm(token) + if (gapEm > 0) { + nodes.push( +
+ ) + } break + } case 'paragraph': nodes.push(renderParagraph(token, key)) break @@ -4268,7 +4296,9 @@ function parseMarkdownContentMarked( const listBody = React.createElement( ListTag, - { className: listClass }, + token.ordered + ? { className: listClass, start: startNum } + : { className: listClass }, items.map((item: any, itemIdx: number) => (
  • { // First unescape JSON-encoded escape sequences let processed = unescapeJsonContent(event.content) - // Normalize excessive newlines (reduce 3+ to 2) - processed = normalizeNewlines(processed) + // Keep multi-newline runs intact so Marked `space` tokens can reproduce intentional vertical gaps. // Normalize single newlines within bold/italic spans to spaces processed = normalizeInlineFormattingNewlines(processed) // Normalize Setext-style headers (H1 with ===, H2 with ---) @@ -5615,6 +5644,13 @@ export default function MarkdownArticle({ return ( <>