Browse Source

make markdown editor more advanced

imwald
Silberengel 2 weeks ago
parent
commit
551e103618
  1. 125
      src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx
  2. 18
      src/components/Image/index.tsx
  3. 68
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 20
      src/components/PaytoLink/index.tsx
  5. 13
      src/i18n/locales/de.ts
  6. 13
      src/i18n/locales/en.ts

125
src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx

@ -176,8 +176,31 @@ export function AdvancedEventLabMarkupToolbar({ @@ -176,8 +176,31 @@ export function AdvancedEventLabMarkupToolbar({
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}>
{t('Advanced lab tb horizontalRule')}
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
`\n${t('Advanced lab tb heading placeholder')}\n===\n`
)
)
}
>
{t('Advanced lab tb setextH1')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
`\n${t('Advanced lab tb heading placeholder')}\n---\n`
)
)
}
>
{t('Advanced lab tb setextH2')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -197,6 +220,12 @@ export function AdvancedEventLabMarkupToolbar({ @@ -197,6 +220,12 @@ export function AdvancedEventLabMarkupToolbar({
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '*', 'italic'))}>
{t('Advanced lab tb italic')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '__', 'bold'))}>
{t('Advanced lab tb boldUnderscore')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '_', 'italic'))}>
{t('Advanced lab tb italicUnderscore')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labWrapOrSnippet(v, sliceRef, '~~', 'strikethrough'))}>
{t('Advanced lab tb strike')}
</DropdownMenuItem>
@ -224,6 +253,44 @@ export function AdvancedEventLabMarkupToolbar({ @@ -224,6 +253,44 @@ export function AdvancedEventLabMarkupToolbar({
<ImageIcon className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb image')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(
v,
sliceRef,
'[',
'link text',
'](https://example.com "Link title")'
)
)
}
>
<Link2 className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb linkTitled')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertSnippet(
v,
sliceRef,
'![',
'alt text',
'](https://example.com/image.png "Image title")'
)
)
}
>
<ImageIcon className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb imageTitled')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, ' \n'))}>
<Pilcrow className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb hardBreak')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -240,10 +307,28 @@ export function AdvancedEventLabMarkupToolbar({ @@ -240,10 +307,28 @@ export function AdvancedEventLabMarkupToolbar({
<List className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb bulletList')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n* item one\n* item two\n'))}>
<List className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb bulletListStar')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n1. first\n2. second\n'))}>
<ListOrdered className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb orderedList')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
labInsertRaw(
v,
sliceRef,
'\n4. item starting at four\n5. next item\n'
)
)
}
>
<ListOrdered className="h-3.5 w-3.5 mr-2 inline" />
{t('Advanced lab tb orderedListStart')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
run((v) =>
@ -454,16 +539,32 @@ export function AdvancedEventLabMarkupToolbar({ @@ -454,16 +539,32 @@ export function AdvancedEventLabMarkupToolbar({
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-xs shrink-0"
title={t('Advanced lab tb hrTitle')}
onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}
>
<Minus className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs shrink-0"
title={t('Advanced lab tb horizontalRules')}
>
<Minus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-48">
<DropdownMenuLabel>{t('Advanced lab tb horizontalRules')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n---\n'))}>
{t('Advanced lab tb hrDashes')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n***\n'))}>
{t('Advanced lab tb hrAsterisks')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => run((v) => labInsertRaw(v, sliceRef, '\n___\n'))}>
{t('Advanced lab tb hrUnderscores')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

18
src/components/Image/index.tsx

@ -50,6 +50,8 @@ export default function Image({ @@ -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 `<img>`. */
tooltipTitle,
...props
}: HTMLAttributes<HTMLSpanElement> & {
classNames?: {
@ -58,6 +60,8 @@ export default function Image({ @@ -58,6 +60,8 @@ export default function Image({
}
image: TImetaInfo
alt?: string
/** Shown as the `<img title>` tooltip when non-empty. */
tooltipTitle?: string
hideIfError?: boolean
errorPlaceholder?: React.ReactNode
/** Passed to the inner `<img>` (e.g. profile banner vs avatar load order). */
@ -93,6 +97,10 @@ export default function Image({ @@ -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({ @@ -216,9 +224,15 @@ export default function Image({
onClick?.(e)
}
const titled = tooltipTitle != null && String(tooltipTitle).trim() !== ''
return (
<span
className={cn('relative overflow-hidden block w-full', classNames.wrapper)}
className={cn(
'relative overflow-hidden block w-full',
classNames.wrapper,
titled && 'cursor-help rounded-lg ring-1 ring-inset ring-dotted ring-muted-foreground/45'
)}
style={mergedWrapperStyle}
onClick={handleWrapperClick}
{...props}
@ -258,7 +272,7 @@ export default function Image({ @@ -258,7 +272,7 @@ export default function Image({
ref={imgRef}
src={imageUrl}
alt={finalAlt}
title={finalAlt || undefined}
title={imgTitle}
referrerPolicy="no-referrer"
decoding={effectiveHoldUntilClick ? 'async' : 'sync'}
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.

68
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -23,6 +23,7 @@ import { @@ -23,6 +23,7 @@ import {
} from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools'
import Emoji from '@/components/Emoji'
import {
@ -613,16 +614,6 @@ function normalizeBackticks(content: string): string { @@ -613,16 +614,6 @@ function normalizeBackticks(content: string): string {
* Note: Only converts if the text line has at least 2 characters to avoid
* creating headers from fragments like "D\n------" which would become "## D"
*/
/**
* Normalize excessive newlines - reduce 3+ consecutive newlines (with optional whitespace) to exactly 2
*/
function normalizeNewlines(content: string): string {
// Match sequences of 3 or more newlines with optional whitespace between them
// Pattern: newline, optional whitespace, newline, optional whitespace, one or more newlines
// Replace with exactly 2 newlines
return content.replace(/\n\s*\n\s*\n+/g, '\n\n')
}
/**
* Normalize single newlines within bold/italic spans to spaces
* This allows bold/italic formatting to work across single line breaks
@ -3237,6 +3228,14 @@ function parseMarkdownContentMarked( @@ -3237,6 +3228,14 @@ function parseMarkdownContentMarked(
}
}
/** CommonMark / GFM optional title in `()` for links and images — surfaced as native `title` + hover cue. */
const markdownTokenTitle = (token: { title?: string | null }): string | undefined => {
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( @@ -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( @@ -3303,7 +3307,8 @@ function parseMarkdownContentMarked(
<PaytoLink
key={`${key}-payto`}
paytoUri={href}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
className={linkVisual}
linkTitle={linkTip}
>
{children}
</PaytoLink>
@ -3315,7 +3320,8 @@ function parseMarkdownContentMarked( @@ -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}
</a>
@ -3331,6 +3337,7 @@ function parseMarkdownContentMarked( @@ -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( @@ -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( @@ -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( @@ -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(
<div
key={`${key}-space`}
className="not-prose pointer-events-none select-none"
aria-hidden
style={{ minHeight: `${gapEm}em` }}
/>
)
}
break
}
case 'paragraph':
nodes.push(renderParagraph(token, key))
break
@ -4268,7 +4296,9 @@ function parseMarkdownContentMarked( @@ -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) => (
<li
key={`${key}-li-${itemIdx}`}
@ -5451,8 +5481,7 @@ export default function MarkdownArticle({ @@ -5451,8 +5481,7 @@ export default function MarkdownArticle({
const preprocessedContent = useMemo(() => {
// 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({ @@ -5615,6 +5644,13 @@ export default function MarkdownArticle({
return (
<>
<style>{`
/* Padding (not margin) so separation does not collapse with the prior list's margin */
.prose > div.break-words > ul + ul,
.prose > div.break-words > ul + ol,
.prose > div.break-words > ol + ul,
.prose > div.break-words > ol + ol {
padding-top: 0.75rem;
}
.prose ol[class*="list-decimal"] {
list-style-type: decimal !important;
}

20
src/components/PaytoLink/index.tsx

@ -23,7 +23,9 @@ export default function PaytoLink({ @@ -23,7 +23,9 @@ export default function PaytoLink({
pubkey,
onOpenZap,
className,
children
children,
/** When set (e.g. Markdown link title), used as the native `title` tooltip instead of the default payto hint. */
linkTitle
}: {
paytoUri?: string
type?: string
@ -33,6 +35,7 @@ export default function PaytoLink({ @@ -33,6 +35,7 @@ export default function PaytoLink({
onOpenZap?: (pubkey: string) => void
className?: string
children?: React.ReactNode
linkTitle?: string
}) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false)
@ -78,6 +81,7 @@ export default function PaytoLink({ @@ -78,6 +81,7 @@ export default function PaytoLink({
const iconChar = getPaytoIconChar(type)
const profileUrl = getPaytoProfileUrl(type, authority)
const content = children ?? <span className="break-all">{authority}</span>
const overrideTip = linkTitle?.trim()
const iconEl = (
<span className="shrink-0 flex items-center justify-center w-4 h-4 text-[1rem] leading-none" aria-hidden>
@ -106,7 +110,10 @@ export default function PaytoLink({ @@ -106,7 +110,10 @@ export default function PaytoLink({
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5',
className
)}
title={categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Open on website')}` : `${displayLabel}: ${t('Open on website')}`}
title={
overrideTip ||
(categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Open on website')}` : `${displayLabel}: ${t('Open on website')}`)
}
onClick={(e) => e.stopPropagation()}
>
{iconEl}
@ -124,7 +131,14 @@ export default function PaytoLink({ @@ -124,7 +131,14 @@ export default function PaytoLink({
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5',
className
)}
title={known && categoryLabel ? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}` : known ? `${displayLabel}: ${t('Click to open payment options')}` : t('Click to copy address')}
title={
overrideTip ||
(known && categoryLabel
? `${displayLabel} (${categoryLabel}): ${t('Click to open payment options')}`
: known
? `${displayLabel}: ${t('Click to open payment options')}`
: t('Click to copy address'))
}
>
{iconEl}
{content}

13
src/i18n/locales/de.ts

@ -989,17 +989,30 @@ export default { @@ -989,17 +989,30 @@ export default {
'Advanced lab tb h4': 'Überschrift 4 (####)',
'Advanced lab tb h5': 'Überschrift 5 (#####)',
'Advanced lab tb h6': 'Überschrift 6 (######)',
'Advanced lab tb setextH1': 'Setext-Überschrift 1 (Titel + ===)',
'Advanced lab tb setextH2': 'Setext-Überschrift 2 (Titel + ---)',
'Advanced lab tb horizontalRule': 'Horizontale Linie (---)',
'Advanced lab tb horizontalRules': 'Horizontale Linien',
'Advanced lab tb hrDashes': 'Bindestriche (---)',
'Advanced lab tb hrAsterisks': 'Sterne (***)',
'Advanced lab tb hrUnderscores': 'Unterstriche (___)',
'Advanced lab tb inline': 'Inline',
'Advanced lab tb bold': 'Fett (** **)',
'Advanced lab tb boldUnderscore': 'Fett (__ __)',
'Advanced lab tb italic': 'Kursiv (* *)',
'Advanced lab tb italicUnderscore': 'Kursiv (_ _)',
'Advanced lab tb strike': 'Durchgestrichen (~~ ~~)',
'Advanced lab tb inlineCode': 'Inline-Code (` `)',
'Advanced lab tb link': 'Link [Text](URL)',
'Advanced lab tb linkTitled': 'Link mit Titel [Text](URL "Titel")',
'Advanced lab tb image': 'Bild ![alt](URL)',
'Advanced lab tb imageTitled': 'Bild mit Titel ![alt](URL "Titel")',
'Advanced lab tb hardBreak': 'Harter Zeilenumbruch (zwei Leerzeichen + Zeilenumbruch)',
'Advanced lab tb lists': 'Listen',
'Advanced lab tb bulletList': 'Aufzählung (-)',
'Advanced lab tb bulletListStar': 'Aufzählung (*)',
'Advanced lab tb orderedList': 'Nummerierte Liste (1.)',
'Advanced lab tb orderedListStart': 'Nummerierte Liste (Startnummer, z. B. 4.)',
'Advanced lab tb taskItem': 'Aufgabe (- [ ])',
'Advanced lab tb blocks': 'Blöcke',
'Advanced lab tb blockquote': 'Zitat (>)',

13
src/i18n/locales/en.ts

@ -990,17 +990,30 @@ export default { @@ -990,17 +990,30 @@ export default {
'Advanced lab tb h4': 'Heading 4 (####)',
'Advanced lab tb h5': 'Heading 5 (#####)',
'Advanced lab tb h6': 'Heading 6 (######)',
'Advanced lab tb setextH1': 'Setext heading 1 (title + ===)',
'Advanced lab tb setextH2': 'Setext heading 2 (title + ---)',
'Advanced lab tb horizontalRule': 'Horizontal rule (---)',
'Advanced lab tb horizontalRules': 'Horizontal rules',
'Advanced lab tb hrDashes': 'Dashes (---)',
'Advanced lab tb hrAsterisks': 'Asterisks (***)',
'Advanced lab tb hrUnderscores': 'Underscores (___)',
'Advanced lab tb inline': 'Inline',
'Advanced lab tb bold': 'Bold (** **)',
'Advanced lab tb boldUnderscore': 'Bold (__ __)',
'Advanced lab tb italic': 'Italic (* *)',
'Advanced lab tb italicUnderscore': 'Italic (_ _)',
'Advanced lab tb strike': 'Strikethrough (~~ ~~)',
'Advanced lab tb inlineCode': 'Inline code (` `)',
'Advanced lab tb link': 'Link [text](url)',
'Advanced lab tb linkTitled': 'Link with title [text](url "title")',
'Advanced lab tb image': 'Image ![alt](url)',
'Advanced lab tb imageTitled': 'Image with title ![alt](url "title")',
'Advanced lab tb hardBreak': 'Hard line break (two spaces + newline)',
'Advanced lab tb lists': 'Lists',
'Advanced lab tb bulletList': 'Bullet list (-)',
'Advanced lab tb bulletListStar': 'Bullet list (*)',
'Advanced lab tb orderedList': 'Numbered list (1.)',
'Advanced lab tb orderedListStart': 'Numbered list (custom start, e.g. 4.)',
'Advanced lab tb taskItem': 'Task item (- [ ])',
'Advanced lab tb blocks': 'Blocks',
'Advanced lab tb blockquote': 'Blockquote (>)',

Loading…
Cancel
Save