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,
+ ''
+ )
+ )
+ }
+ >
+
+ {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 ``). 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 (
<>