import { $ , CARD_KEY , EDITABLE_SELECTOR , isEngine , NodeInterface , Plugin , SchemaBlock , SchemaInterface , getDocument , READY_CARD_KEY , decodeCardValue , CARD_VALUE_KEY , CARD_SELECTOR , transformCustomTags , } from '@aomao/engine' ; import TableComponent , { Template , Helper } from './component' ; import locales from './locale' ; import { TableInterface , TableOptions , TableValue } from './types' ; import './index.css' ; import { DATA_ID } from '@aomao/engine' ; import { CardInterface } from '@aomao/engine' ; class Table < T extends TableOptions = TableOptions > extends Plugin < T > { static get pluginName ( ) { return 'table' ; } init ( ) { const editor = this . editor ; if ( ! this . options . colMinWidth ) { this . options . colMinWidth = 40 ; } if ( ! this . options . rowMinHeight ) { this . options . rowMinHeight = 30 ; } editor . language . add ( locales ) ; editor . schema . add ( this . schema ( ) ) ; editor . conversion . add ( 'th' , 'td' ) ; editor . on ( 'parse:html' , ( node ) => this . parseHtml ( node ) ) ; editor . on ( 'paste:each-after' , ( root ) => this . pasteHtml ( root ) ) ; editor . on ( 'paste:schema' , ( schema ) => this . pasteSchema ( schema ) ) ; editor . on ( 'paste:markdown-check' , ( child ) => ! this . checkMarkdown ( child ) ?. match ) ; editor . on ( 'paste:markdown-after' , ( child ) => this . pasteMarkdown ( child ) ) ; if ( isEngine ( editor ) ) { editor . change . event . onDocument ( 'copy' , ( event ) => this . onCopy ( event ) , 0 ) ; editor . change . event . onDocument ( 'cut' , ( event ) => this . onCut ( event ) , 0 ) ; editor . change . event . onDocument ( 'paste' , ( event ) => this . onPaste ( event ) , 0 ) ; const targetTableCache = { } ; editor . history . onFilter ( ( op ) => { if ( op . id && ( ( 'od' in op && ( op . od . startsWith ( 'width' ) || op . od === op . id || op . od . startsWith ( 'data:' ) || /^\d+$/ . test ( op . od ) ) ) || ( 'oi' in op && ( op . oi . startsWith ( 'width' ) || op . oi === op . id || op . oi . startsWith ( 'data:' ) || /^\d+$/ . test ( op . oi ) ) ) ) ) { let component = targetTableCache [ op . id ] ; if ( ! component || ! component . root . parent ( ) ) { const targetNode = $ ( `[${ DATA_ID }="${ op . id }"]` ) ; delete targetTableCache [ op . id ] ; if ( targetNode . length > 0 ) { component = editor . card . find < TableValue , TableComponent > ( targetNode ) ; } } if ( component ?. name === TableComponent . cardName && ! component . isChanged ) { op [ 'nl' ] = true ; return true ; } } return false ; } ) ; } } onCopy ( event ) { if ( ! isEngine ( this . editor ) ) return true ; const { change , card } = this . editor ; const range = change . range . get ( ) ; const component = card . find < TableValue , TableComponent > ( range . commonAncestorNode , true ) ; if ( component && component . getSelectionNodes && component . name === TableComponent . cardName ) { const nodes = component . getSelectionNodes ( ) ; if ( nodes . length > 1 ) { event . preventDefault ( ) ; component . command . copy ( ) ; this . editor . messageSuccess ( this . editor . language . get < string > ( 'copy' , 'success' ) ) ; return false ; } } return true ; } onCut ( event ) { if ( ! isEngine ( this . editor ) ) return true ; const { change , card } = this . editor ; const range = change . range . get ( ) ; const component = card . find < TableValue , TableComponent > ( range . commonAncestorNode , true ) ; if ( component && component . getSelectionNodes && component . name === TableComponent . cardName ) { const nodes = component . getSelectionNodes ( ) ; if ( nodes . length > 1 ) { event . preventDefault ( ) ; component . command . cut ( ) ; return false ; } } return true ; } onPaste ( event ) { if ( ! isEngine ( this . editor ) ) return true ; const { change , card } = this . editor ; const range = change . range . get ( ) ; const component = card . find < TableValue , TableComponent > ( range . commonAncestorNode , true ) ; if ( component && component . getSelectionNodes && component . name === TableComponent . cardName && component . command . hasCopyData ( ) ) { event . preventDefault ( ) ; component . command . mockPaste ( ) ; return false ; } return true ; } hotkey ( ) { return this . options . hotkey || '' ; } schema ( ) { return [ { name : 'table' , type : 'block' , attributes : { class : [ 'data-table' ] , 'data-table-no-border' : '*' , style : { width : '@length' , } , } , } , { name : 'colgroup' , type : 'block' , allowIn : [ 'table' ] , } , { name : 'col' , type : 'block' , isVoid : true , attributes : { width : '@number' , span : '@number' , } , allowIn : [ 'colgroup' ] , } , { name : 'thead' , type : 'block' , allowIn : [ 'table' ] , } , { name : 'tbody' , type : 'block' , allowIn : [ 'table' ] , } , { name : 'tr' , type : 'block' , attributes : { style : { height : '@length' , } , } , allowIn : [ 'tbody' , 'thead' , 'tfoot' ] , } , { name : 'td' , type : 'block' , attributes : { colspan : '@number' , rowspan : '@number' , class : [ 'table-last-column' , 'table-last-row' , 'table-last-column' , 'table-cell-selection' , ] , style : { 'background-color' : '@color' , 'vertical-align' : [ 'top' , 'middle' , 'bottom' ] , } , } , allowIn : [ 'tr' ] , } , { name : 'th' , type : 'block' , attributes : { colspan : '@number' , rowspan : '@number' , } , allowIn : [ 'tr' ] , } , ] ; } pasteSchema ( schema ) { ( schema . data . blocks as SchemaBlock [ ] ) . forEach ( ( blockSchema ) => { if ( ! blockSchema . allowIn ) { blockSchema . allowIn = [ ] ; } if ( blockSchema . allowIn . indexOf ( 'td' ) < 0 ) { blockSchema . allowIn . push ( 'td' ) ; } } ) ; schema . find ( ( r ) => r . name === 'table' ) [ 0 ] . attributes = { class : [ 'data-table' ] , 'data-table-no-border' : '*' , 'data-wdith' : '@length' , style : { width : '@length' , background : '@color' , 'background-color' : '@color' , } , } ; schema . find ( ( r ) => r . name === 'tr' ) [ 0 ] . attributes = { class : [ 'data-table' ] , 'data-table-no-border' : '*' , style : { width : '@length' , background : '@color' , 'background-color' : '@color' , } , } ; schema . find ( ( r ) => r . name === 'td' ) [ 0 ] . attributes = { colspan : '@number' , rowspan : '@number' , class : [ 'table-last-column' , 'table-last-row' , 'table-last-column' , 'table-cell-selection' , ] , style : { 'background-color' : '@color' , background : '@color' , 'vertical-align' : [ 'top' , 'middle' , 'bottom' ] , valign : [ 'top' , 'middle' , 'bottom' ] , } , } ; } execute ( rows ? , cols ? ) { if ( ! isEngine ( this . editor ) ) return ; const { change } = this . editor ; const range = change . range . get ( ) ; if ( range . startNode . closest ( EDITABLE_SELECTOR ) . length > 0 ) return ; this . editor . card . insert < TableValue > ( TableComponent . cardName , { rows : rows || 3 , cols : cols || 3 , overflow : ! ! this . options . overflow , } ) ; } convertToPX ( value ) { const match = /([\d\.]+)(pt|px)$/i . exec ( value ) ; if ( match && match [ 2 ] === 'pt' ) { return ( String ( Math . round ( ( parseInt ( match [ 1 ] , 10 ) * 96 ) / 72 ) ) + 'px' ) ; } return value ; } pasteHtml ( root ) { if ( ! isEngine ( this . editor ) ) return ; const clearWH = ( node , type = 'width' ) => { const dataWidth = node . attributes ( 'data-width' ) ; const width = dataWidth ? dataWidth : node . css ( type ) ; if ( width . endsWith ( '%' ) ) node . css ( type , '' ) ; if ( width . endsWith ( 'pt' ) ) node . css ( type , this . convertToPX ( width ) ) ; } ; const tables = root . find ( 'table' ) ; if ( tables . length === 0 ) return ; const helper = new Helper ( this . editor ) ; const { change } = this . editor ; const range = change . range . get ( ) ; const clearTable = ( table ) => { const thead = table . find ( 'thead' ) ; const headTds = thead . find ( 'th,td' ) . toArray ( ) ; headTds . forEach ( ( td ) => { table . before ( td . children ( ) ) ; } ) ; const trs = table . find ( 'tr' ) . toArray ( ) ; trs . forEach ( ( tr ) => { const tds = tr . find ( 'td' ) . toArray ( ) ; tds . forEach ( ( td ) => { if ( ! this . editor . node . isEmpty ( td ) ) table . before ( td . children ( ) ) ; } ) ; } ) ; const tfoot = table . find ( 'tfoot' ) ; const footTds = tfoot . find ( 'th,td' ) . toArray ( ) ; footTds . forEach ( ( td ) => { table . before ( td . children ( ) ) ; } ) ; table . remove ( ) ; } ; const isClear = range . startNode . closest ( EDITABLE_SELECTOR ) . length > 0 ; tables . each ( ( _ , index ) => { let node = tables . eq ( index ) ; if ( ! node ) return ; if ( isClear || node . parent ( ) ?. name === 'td' ) { clearTable ( node ) ; return ; } node = helper . normalizeTable ( node ) ; clearWH ( node ) ; clearWH ( node , 'height' ) ; const tbody = node . find ( 'tbody' ) ; const thead = node . find ( 'thead' ) ; if ( thead && thead . length > 0 ) tbody . prepend ( thead . children ( ) ) ; thead . remove ( ) ; const tfoot = node . find ( 'tfoot' ) ; if ( tfoot && tfoot . length > 0 ) tbody . append ( tfoot . children ( ) ) ; tfoot . remove ( ) ; const ths = node . find ( 'th' ) ; ths . each ( ( _ , index ) => { const th = ths . eq ( index ) ; th ?. replaceWith ( $ ( `<td>${ th . html ( ) }</td>` ) ) ; } ) ; const tds = node . find ( 'td' ) ; let fragment = getDocument ( ) . createDocumentFragment ( ) ; tds . each ( ( _ , index ) => { fragment = getDocument ( ) . createDocumentFragment ( ) ; const element = tds . eq ( index ) ; if ( ! element ) return ; clearWH ( element ) ; clearWH ( element , 'height' ) ; const background = element . css ( 'background' ) ; if ( background ) element . css ( 'background-color' , background ) ; const valign = element . attributes ( 'valign' ) ; if ( valign ) element . attributes ( 'vertical-align' , valign ) ; const children = element . children ( ) ; for ( let i = 0 ; i < children . length ; i ++ ) { const child = children . eq ( i ) ; if ( child ) fragment . appendChild ( child [ 0 ] ) ; } const fragmentNode = $ ( fragment ) ; element ?. empty ( ) . append ( this . editor . node . normalize ( fragmentNode ) ) ; } ) ; const background = node ?. css ( 'background' ) || node ?. css ( 'background-color' ) ; if ( background ) tds . css ( 'background' , background ) ; const trs = node . find ( 'tr' ) ; let rowSpan = 1 ; trs . each ( ( _ , index ) => { const element = trs . eq ( index ) ; const tds = element ?. find ( 'td' ) ; if ( tds ?. length === 0 && rowSpan < 2 ) { element ?. remove ( ) ; } if ( tds && tds ?. length > 0 ) { rowSpan = ( tds [ 0 ] as HTMLTableCellElement ) . rowSpan ; } else { rowSpan = 1 ; } if ( element ) { clearWH ( element ) ; clearWH ( element , 'height' ) ; } const background = element ?. css ( 'background' ) || element ?. css ( 'background-color' ) ; if ( background ) tds ?. css ( 'background' , background ) ; } ) ; this . editor . nodeId . generateAll ( node , true ) ; const children = node . allChildren ( ) ; children . forEach ( ( child ) => { if ( this . editor . node . isInline ( child ) ) { this . editor . inline . repairCursor ( child ) ; } } ) ; const html = node . get < HTMLElement > ( ) ! . outerHTML . replace ( /\n|\r\n/g , '' ) . replace ( />\s+</g , '><' ) ; this . editor . card . replaceNode < TableValue > ( node , TableComponent . cardName , { html , } ) ; } ) ; } parseHtml ( root ) { root . find ( `[${ CARD_KEY }="${ TableComponent . cardName }"],[${ READY_CARD_KEY }="${ TableComponent . cardName }"]` ) . each ( ( tableNode ) => { const node = $ ( tableNode ) ; let table = node . find ( 'table' ) ; if ( table . length === 0 ) { const value = decodeCardValue < TableValue > ( node . attributes ( CARD_VALUE_KEY ) ) ; if ( ! value || ! value . html ) return ; table = $ ( transformCustomTags ( value . html ) ) ; if ( table . length === 0 ) { node . remove ( ) ; return ; } else { const cards = table . find ( CARD_SELECTOR ) . toArray ( ) ; cards . forEach ( ( componentNode ) => { this . editor . trigger ( 'parse:html' , componentNode ) ; } ) ; } } const width = table . attributes ( 'width' ) || table . css ( 'width' ) ; table . css ( { outline : 'none' , 'border-collapse' : 'collapse' , width : '100%' , } ) ; table . attributes ( 'data-width' , width ) ; const tds = table . find ( 'td' ) ; tds . each ( ( _ , index ) => { const tdElement = tds . eq ( index ) ; tdElement ?. css ( { 'min-width' : 'auto' , 'white-space' : 'flat' , 'word-wrap' : 'break-word' , margin : '4px 8px' , border : ! ! table . attributes ( 'data-table-no-border' ) ? '0 none' : '1px solid #d9d9d9' , padding : '4px 8px' , cursor : 'default' , 'vertical-align' : tdElement . css ( 'vertical-align' ) || 'top' , } ) ; } ) ; table . find ( Template . TABLE_TD_BG_CLASS ) . remove ( ) ; table . find ( Template . TABLE_TD_CONTENT_CLASS ) . each ( ( content ) => { this . editor . node . unwrap ( $ ( content ) ) ; } ) ; node . replaceWith ( table ) ; } ) ; } getMarkdownCell ( match , count ? ) { const cols = match [ 0 ] . split ( '|' ) ; const headeText = match [ 0 ] . trim ( ) . replace ( /\n/ , '' ) ; if ( headeText . endsWith ( '|' ) ) cols . pop ( ) ; if ( headeText . startsWith ( '|' ) ) cols . shift ( ) ; const colNodes = [ ] ; cols . some ( ( col ) => { if ( count !== undefined && colNodes . length === count ) return true ; colNodes . push ( col ) ; return false ; } ) ; return colNodes ; } checkMarkdown ( node ) { if ( ! isEngine ( this . editor ) || this . options . markdown === false || ! node . isText ( ) ) return ; const text = node . text ( ) ; if ( ! text ) return ; const reg = /\n\s*(\|?(\s*:?-+:?\s*)+\|?)+\s*(\n|$)/ ; const tbMatch = reg . exec ( text ) ; if ( ! tbMatch || tbMatch [ 0 ] . indexOf ( '|' ) < 0 ) return ; return { reg , match : tbMatch , } ; } pasteMarkdown ( node ) { const result = this . checkMarkdown ( node ) ; if ( ! result ) return ; const { reg , match } = result ; if ( ! match ) return ; const parse = ( node ) => { let text = node . text ( ) ; if ( ! text ) return ; const tbMatch = reg . exec ( text ) ; if ( ! tbMatch || tbMatch [ 0 ] . indexOf ( '|' ) < 0 ) return ; const textNode = node . clone ( true ) . get < Text > ( ) ! ; const colCount = tbMatch [ 0 ] . split ( '|' ) . filter ( ( cell ) => cell . trim ( ) !== '' && cell . includes ( '-' ) ) . length ; const tbRegNode = textNode . splitText ( tbMatch . index ) ; const thReg = new RegExp ( `(\\|?([^\\|\\n]+)\\|?){${ colCount },}\\s*$` ) ; const headRows = ( textNode . textContent || '' ) . split ( /\n/ ) ; let match = thReg . exec ( headRows . length > 0 ? headRows [ headRows . length - 1 ] : '' ) ; if ( ! match || match [ 0 ] . indexOf ( '|' ) < 0 ) return ; headRows . pop ( ) ; textNode . splitText ( headRows . join ( '\n' ) . length + match . index ) ; let regNode = tbRegNode . splitText ( tbMatch [ 0 ] . length ) ; let newText = textNode . textContent || '' ; const colNodes = this . getMarkdownCell ( match ) ; if ( colNodes . length !== colCount ) return ; const nodes = [ ] ; nodes . push ( `<tr>${ colNodes . map ( ( col ) => `<td>${ col }</td>` ) . join ( '' ) }</tr>` ) ; const tdReg = new RegExp ( `^\\n*(\\|?([^\\|\\n]+)\\|?){1,${ colCount }}(?:\\n|$)` ) ; while ( match ) { match = tdReg . exec ( regNode . textContent || '' ) ; if ( ! match || match [ 0 ] . indexOf ( '|' ) < 0 || match [ 0 ] . startsWith ( '\n\n' ) ) break ; const colNodes = this . getMarkdownCell ( match , colCount ) ; if ( colNodes . length === 0 ) break ; if ( colNodes . length < colCount ) { while ( colCount - colNodes . length > 0 ) { colNodes . push ( '' ) ; } } nodes . push ( `<tr>${ colNodes . map ( ( col ) => `<td>${ col }</td>` ) . join ( '' ) }</tr>` ) ; regNode = regNode . splitText ( match [ 0 ] . length ) ; } const createTable = ( nodes ) => { const tableNode = $ ( `<table>${ nodes . join ( '' ) }</table>` ) ; return tableNode . get < Element > ( ) ?. outerHTML ; } ; newText += createTable ( nodes ) + '\n' ; newText += regNode . textContent ; node . text ( newText ) ; parse ( node ) ; } ; parse ( node ) ; } } export default Table ; export { TableComponent } ; export type { TableValue , TableOptions } ;