@@ -61,6 +61,9 @@ export type ImageUploadHandler = (
6161
6262const DEFAULT_MAX_IMAGE_BYTES = 1_000_000 ;
6363const UPLOADED_IMAGE_PRELOAD_TIMEOUT_MS = 8_000 ;
64+ const MARKDOWN_TABLE_ROW_PATTERN = / ^ \s * \| .* \| \s * $ / ;
65+ const MARKDOWN_TABLE_DELIMITER_CELL_PATTERN = / ^ : ? - { 3 , } : ? $ / ;
66+ const TABLE_CELL_NBSP_PATTERN = / ^ (?: & n b s p ; | \u00A0 ) + $ / i;
6467const UploadableImage = Image . extend ( {
6568 addAttributes ( ) {
6669 return {
@@ -183,6 +186,52 @@ const toUploadableAttrs = (attrs: unknown): UploadableImageAttrs => {
183186 return attrs as UploadableImageAttrs ;
184187} ;
185188
189+ const splitMarkdownTableCells = ( line : string ) : string [ ] => {
190+ const trimmed = line . trim ( ) ;
191+ if ( trimmed . length < 2 || ! trimmed . startsWith ( "|" ) || ! trimmed . endsWith ( "|" ) ) return [ ] ;
192+
193+ const row = trimmed . slice ( 1 , - 1 ) ;
194+ const cells : string [ ] = [ ] ;
195+ let start = 0 ;
196+
197+ for ( let index = 0 ; index < row . length ; index += 1 ) {
198+ if ( row [ index ] !== "|" ) continue ;
199+
200+ let slashCount = 0 ;
201+ for ( let slashIndex = index - 1 ; slashIndex >= 0 && row [ slashIndex ] === "\\" ; slashIndex -= 1 ) {
202+ slashCount += 1 ;
203+ }
204+ if ( slashCount % 2 === 1 ) continue ;
205+
206+ cells . push ( row . slice ( start , index ) ) ;
207+ start = index + 1 ;
208+ }
209+
210+ cells . push ( row . slice ( start ) ) ;
211+ return cells ;
212+ } ;
213+
214+ const isMarkdownTableDelimiterLine = ( line : string ) : boolean => {
215+ if ( ! MARKDOWN_TABLE_ROW_PATTERN . test ( line ) ) return false ;
216+ const cells = splitMarkdownTableCells ( line ) ;
217+ if ( ! cells . length ) return false ;
218+ return cells . every ( ( cell ) => MARKDOWN_TABLE_DELIMITER_CELL_PATTERN . test ( cell . trim ( ) ) ) ;
219+ } ;
220+
221+ const normalizeMarkdownTables = ( markdown : string ) : string =>
222+ markdown
223+ . split ( "\n" )
224+ . map ( ( line ) => {
225+ if ( ! MARKDOWN_TABLE_ROW_PATTERN . test ( line ) || isMarkdownTableDelimiterLine ( line ) ) return line ;
226+
227+ const cells = splitMarkdownTableCells ( line ) ;
228+ if ( ! cells . length ) return line ;
229+
230+ const normalizedCells = cells . map ( ( cell ) => ( TABLE_CELL_NBSP_PATTERN . test ( cell . trim ( ) ) ? "" : cell . trim ( ) ) ) ;
231+ return `| ${ normalizedCells . join ( " | " ) } |` ;
232+ } )
233+ . join ( "\n" ) ;
234+
186235const blockOptions : Array < { value : BlockType ; label : string } > = [
187236 { value : "paragraph" , label : "Text" } ,
188237 { value : "heading1" , label : "Heading 1" } ,
@@ -224,7 +273,7 @@ export function Editor({
224273 const objectUrlByUploadIdRef = useRef ( new Map < string , string > ( ) ) ;
225274 const expectedBlobByUploadIdRef = useRef ( new Map < string , string > ( ) ) ;
226275 const tiptapSurfaceClass = cn (
227- "border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background" ,
276+ "border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] md:text-sm [&_p.is-empty::before]:text-muted-foreground [&_p.is-empty::before]:content-[attr(data-placeholder)] [&_p.is-empty::before]:pointer-events-none [&_p.is-empty::before]:float-left [&_p.is-empty::before]:h-0 [&_td_p.is-empty::before]:content-none [&_th_p.is-empty::before]:content-none [& _img[data-uploading=true]]:opacity-70 [&_img[data-uploading=true]]:animate-pulse [&_img[data-upload-error]]:ring-2 [&_img[data-upload-error]]:ring-destructive [&_img[data-upload-error]]:ring-offset-2 [&_img[data-upload-error]]:ring-offset-background" ,
228277 editorClassName ,
229278 ) ;
230279
@@ -249,8 +298,18 @@ export function Editor({
249298 TableHeader ,
250299 TableCell ,
251300 Placeholder . configure ( {
252- placeholder : ( { node } : { node : ProseMirrorNode } ) : string =>
253- node . type . name === "paragraph" ? "Press '/' for commands" : "" ,
301+ placeholder : ( {
302+ node,
303+ editor : currentEditor ,
304+ } : {
305+ node : ProseMirrorNode ;
306+ editor : TiptapEditor ;
307+ } ) : string =>
308+ node . type . name === "paragraph" &&
309+ ! currentEditor . isActive ( "tableCell" ) &&
310+ ! currentEditor . isActive ( "tableHeader" )
311+ ? "Press '/' for commands"
312+ : "" ,
254313 showOnlyCurrent : true ,
255314 includeChildren : true ,
256315 } ) ,
@@ -330,7 +389,7 @@ export function Editor({
330389 onUpdate : ( { editor : nextEditor } ) => {
331390 const nextValue =
332391 format === "markdown"
333- ? nextEditor . getMarkdown ( )
392+ ? normalizeMarkdownTables ( nextEditor . getMarkdown ( ) )
334393 : nextEditor
335394 . getHTML ( )
336395 . replace ( / \s d a t a - u p l o a d - i d = " [ ^ " ] * " / g, "" )
@@ -380,7 +439,7 @@ export function Editor({
380439 if ( ! editor ) return ;
381440 if ( value === lastEmittedValueRef . current ) return ;
382441
383- const current = format === "markdown" ? editor . getMarkdown ( ) : editor . getHTML ( ) ;
442+ const current = format === "markdown" ? normalizeMarkdownTables ( editor . getMarkdown ( ) ) : editor . getHTML ( ) ;
384443 const hasChanged =
385444 format === "markdown" ? value . trimEnd ( ) !== current . trimEnd ( ) : value !== current ;
386445
@@ -880,26 +939,28 @@ export function Editor({
880939 >
881940 < div className = "flex flex-col gap-1" >
882941 < div className = "border-border bg-popover flex flex-nowrap items-center gap-0.5 overflow-x-auto rounded-md border p-1 shadow-sm whitespace-nowrap" >
883- < div className = "group/native-select relative w-fit" >
884- < select
885- id = "block-style"
886- value = { activeState . blockType }
887- onChange = { ( event ) => setBlockType ( event . target . value as BlockType ) }
888- disabled = { disabled }
889- aria-label = "Block style"
890- className = "h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
891- >
892- { blockOptions . map ( ( option ) => (
893- < option key = { option . value } value = { option . value } >
894- { option . label }
895- </ option >
896- ) ) }
897- </ select >
898- < ChevronDownIcon
899- className = "text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
900- aria-hidden = "true"
901- />
902- </ div >
942+ { ! isInTable ? (
943+ < div className = "group/native-select relative w-fit" >
944+ < select
945+ id = "block-style"
946+ value = { activeState . blockType }
947+ onChange = { ( event ) => setBlockType ( event . target . value as BlockType ) }
948+ disabled = { disabled }
949+ aria-label = "Block style"
950+ className = "h-7 w-full appearance-none rounded-md border border-transparent bg-transparent px-2 pr-5.5 text-sm shadow-none outline-none hover:bg-accent focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
951+ >
952+ { blockOptions . map ( ( option ) => (
953+ < option key = { option . value } value = { option . value } >
954+ { option . label }
955+ </ option >
956+ ) ) }
957+ </ select >
958+ < ChevronDownIcon
959+ className = "text-muted-foreground pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 opacity-50"
960+ aria-hidden = "true"
961+ />
962+ </ div >
963+ ) : null }
903964 { inlineActions . map ( ( action ) =>
904965 renderIconButton ( {
905966 label : action . label ,
@@ -957,6 +1018,7 @@ export function Editor({
9571018 onKeyDown = { ( event ) => {
9581019 if ( event . key === "Enter" ) {
9591020 event . preventDefault ( ) ;
1021+ event . stopPropagation ( ) ;
9601022 applyLink ( ) ;
9611023 }
9621024 } }
@@ -989,6 +1051,13 @@ export function Editor({
9891051 placeholder = "Describe image"
9901052 value = { imageAltText }
9911053 onChange = { ( event ) => setImageAltText ( event . target . value ) }
1054+ onKeyDown = { ( event ) => {
1055+ if ( event . key === "Enter" ) {
1056+ event . preventDefault ( ) ;
1057+ event . stopPropagation ( ) ;
1058+ applyImageAlt ( ) ;
1059+ }
1060+ } }
9921061 disabled = { disabled }
9931062 className = { `${ toolbarInputClass } min-w-56 flex-1` }
9941063 />
0 commit comments