@@ -6,6 +6,16 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
66
77const MIN_WIDTH = 64
88
9+ /**
10+ * A markdown linked image `[](href "t2")` — an image wrapped in a link, the canonical
11+ * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an
12+ * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize
13+ * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it
14+ * round-trips losslessly (and the file stays editable rather than opening read-only).
15+ */
16+ const LINKED_IMAGE_RE =
17+ / ^ \[ ! \[ ( [ ^ \] ] * ) \] \( ( [ ^ ) \s ] + ) (?: \s + " ( [ ^ " ] * ) " ) ? \) \] \( ( [ ^ ) \s ] + ) (?: \s + " ( [ ^ " ] * ) " ) ? \) /
18+
919/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
1020function escapeAttr ( value : string ) : string {
1121 return value
@@ -18,25 +28,68 @@ function escapeAttr(value: string): string {
1828/**
1929 * Serialize an image to markdown when it has no explicit size, and to an HTML `<img>` tag when
2030 * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to
21- * preserve its dimensions. Unsized images stay clean ``.
31+ * preserve its dimensions. Unsized images stay clean ``. An image with an `href` is
32+ * wrapped in a markdown link so a linked badge round-trips as `[](href)`.
2233 */
2334function imageMarkdown ( node : JSONContent ) : string {
2435 const attrs = node . attrs ?? { }
2536 const src = typeof attrs . src === 'string' ? attrs . src : ''
2637 const alt = typeof attrs . alt === 'string' ? attrs . alt : ''
2738 const title = typeof attrs . title === 'string' ? attrs . title : ''
39+ const href = typeof attrs . href === 'string' ? attrs . href : ''
40+ const hrefTitle = typeof attrs . hrefTitle === 'string' ? attrs . hrefTitle : ''
2841 const width = attrs . width
2942 const height = attrs . height
43+ let image : string
3044 if ( width || height ) {
3145 const parts = [ `src="${ escapeAttr ( src ) } "` ]
3246 if ( alt ) parts . push ( `alt="${ escapeAttr ( alt ) } "` )
3347 if ( title ) parts . push ( `title="${ escapeAttr ( title ) } "` )
3448 if ( width ) parts . push ( `width="${ escapeAttr ( String ( width ) ) } "` )
3549 if ( height ) parts . push ( `height="${ escapeAttr ( String ( height ) ) } "` )
36- return `<img ${ parts . join ( ' ' ) } >`
50+ image = `<img ${ parts . join ( ' ' ) } >`
51+ } else {
52+ const titlePart = title ? ` "${ title } "` : ''
53+ image = ``
54+ }
55+ if ( ! href ) return image
56+ const hrefTitlePart = hrefTitle ? ` "${ hrefTitle } "` : ''
57+ return `[${ image } ](${ href } ${ hrefTitlePart } )`
58+ }
59+
60+ interface MarkdownImageToken {
61+ /** Set only by our linked-image tokenizer; absent on the built-in `` token. */
62+ src ?: string
63+ alt ?: string
64+ title ?: string | null
65+ /** Built-in image token holds the source URL here; our linked token holds the link target. */
66+ href ?: string
67+ hrefTitle ?: string | null
68+ /** Built-in image token holds the alt text here. */
69+ text ?: string
70+ }
71+
72+ /** Map both the built-in image token and our linked-image token onto the image node's attributes. */
73+ function parseImageToken ( token : MarkdownImageToken ) : JSONContent {
74+ const isLinked = typeof token . src === 'string'
75+ return {
76+ type : 'image' ,
77+ attrs : isLinked
78+ ? {
79+ src : token . src ,
80+ alt : token . alt ?? '' ,
81+ title : token . title ?? null ,
82+ href : token . href ?? null ,
83+ hrefTitle : token . hrefTitle ?? null ,
84+ }
85+ : {
86+ src : token . href ?? '' ,
87+ alt : token . text ?? '' ,
88+ title : token . title ?? null ,
89+ href : null ,
90+ hrefTitle : null ,
91+ } ,
3792 }
38- const titlePart = title ? ` "${ title } "` : ''
39- return ``
4093}
4194
4295const widthAttr = {
@@ -53,14 +106,44 @@ const heightAttr = {
53106 attributes . height ? { height : String ( attributes . height ) } : { } ,
54107}
55108
109+ /** Link target of a linked image — markdown-only state, never emitted as an HTML `<img>` attribute. */
110+ const hrefAttr = { default : null , rendered : false }
111+ const hrefTitleAttr = { default : null , rendered : false }
112+
56113/**
57- * Image node that carries optional `width`/`height` and serializes them as an HTML `<img>` tag.
58- * Shared by the headless round-trip path (no node view) and the live {@link ResizableImage}.
114+ * Image node that carries optional `width`/`height` (serialized as an HTML `<img>` tag) and an
115+ * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless
116+ * round-trip path (no node view) and the live {@link ResizableImage}.
59117 */
60118export const MarkdownImage = Image . extend ( {
61119 addAttributes ( ) {
62- return { ...this . parent ?.( ) , width : widthAttr , height : heightAttr }
120+ return {
121+ ...this . parent ?.( ) ,
122+ width : widthAttr ,
123+ height : heightAttr ,
124+ href : hrefAttr ,
125+ hrefTitle : hrefTitleAttr ,
126+ }
63127 } ,
128+ markdownTokenizer : {
129+ name : 'image' ,
130+ level : 'inline' ,
131+ start : ( src : string ) => src . indexOf ( '[![' ) ,
132+ tokenize : ( src : string ) : ( MarkdownImageToken & { type : string ; raw : string } ) | undefined => {
133+ const match = LINKED_IMAGE_RE . exec ( src )
134+ if ( ! match ) return undefined
135+ return {
136+ type : 'image' ,
137+ raw : match [ 0 ] ,
138+ alt : match [ 1 ] ?? '' ,
139+ src : match [ 2 ] ,
140+ title : match [ 3 ] ?? null ,
141+ href : match [ 4 ] ,
142+ hrefTitle : match [ 5 ] ?? null ,
143+ }
144+ } ,
145+ } ,
146+ parseMarkdown : parseImageToken ,
64147 renderMarkdown : imageMarkdown ,
65148} )
66149
@@ -72,7 +155,13 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
72155 const imageRef = useRef < HTMLImageElement > ( null )
73156 const dragAbortRef = useRef < AbortController | null > ( null )
74157 const [ dragging , setDragging ] = useState ( false )
75- const attrs = node . attrs as { src ?: string ; alt ?: string ; title ?: string ; width ?: string | null }
158+ const attrs = node . attrs as {
159+ src ?: string
160+ alt ?: string
161+ title ?: string
162+ width ?: string | null
163+ href ?: string | null
164+ }
76165
77166 useEffect ( ( ) => ( ) => dragAbortRef . current ?. abort ( ) , [ ] )
78167
@@ -110,17 +199,31 @@ function ResizableImageView({ node, updateAttributes, selected }: ReactNodeViewP
110199 ? { width : / ^ \d + $ / . test ( attrs . width ) ? `${ attrs . width } px` : attrs . width }
111200 : undefined
112201
202+ const image = (
203+ < img
204+ ref = { imageRef }
205+ src = { attrs . src }
206+ alt = { attrs . alt ?? '' }
207+ title = { attrs . title ?? undefined }
208+ // The image itself is the drag handle — grab anywhere on it to reorder. (The node view's
209+ // wrapper is forced `draggable=false` by the React renderer, so the handle must be a child;
210+ // the resize button sits outside this element, so it keeps its own pointer behavior.)
211+ draggable
212+ data-drag-handle
213+ style = { widthStyle }
214+ className = 'block max-w-full cursor-grab rounded-lg border border-[var(--border)]'
215+ />
216+ )
217+
113218 return (
114219 < NodeViewWrapper className = 'relative my-4 inline-block leading-none' >
115- < img
116- ref = { imageRef }
117- src = { attrs . src }
118- alt = { attrs . alt ?? '' }
119- title = { attrs . title ?? undefined }
120- draggable = { false }
121- style = { widthStyle }
122- className = 'block max-w-full rounded-lg border border-[var(--border)]'
123- />
220+ { attrs . href ? (
221+ < a href = { attrs . href } rel = 'noopener noreferrer' className = 'block' >
222+ { image }
223+ </ a >
224+ ) : (
225+ image
226+ ) }
124227 { ( selected || dragging ) && (
125228 < button
126229 type = 'button'
0 commit comments