2023-04-03 18:06:57 +08:00
import '@github/markdown-toolbar-element' ;
2023-04-09 18:18:45 +02:00
import '@github/text-expander-element' ;
2023-04-08 01:03:29 +08:00
import $ from 'jquery' ;
2023-04-03 18:06:57 +08:00
import { attachTribute } from '../tribute.js' ;
2023-04-08 01:03:29 +08:00
import { hideElem , showElem , autosize } from '../../utils/dom.js' ;
2023-04-03 18:06:57 +08:00
import { initEasyMDEImagePaste , initTextareaImagePaste } from './ImagePaste.js' ;
import { handleGlobalEnterQuickSubmit } from './QuickSubmit.js' ;
2023-04-12 11:03:23 +08:00
import { renderPreviewPanelContent } from '../repo-editor.js' ;
2023-05-03 07:23:39 +02:00
import { easyMDEToolbarActions } from './EasyMDEToolbarActions.js' ;
2023-05-09 07:22:52 +09:00
import { initTextExpander } from './TextExpander.js' ;
2023-04-03 18:06:57 +08:00
let elementIdCounter = 0 ;
/ * *
* validate if the given textarea is non - empty .
* @ param { jQuery } $textarea
* @ returns { boolean } returns true if validation succeeded .
* /
export function validateTextareaNonEmpty ( $textarea ) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if ( ! $textarea . val ( ) ) {
if ( $textarea . is ( ':visible' ) ) {
$textarea . prop ( 'required' , true ) ;
const $form = $textarea . parents ( 'form' ) ;
$form [ 0 ] ? . reportValidity ( ) ;
} else {
// The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
alert ( 'Require non-empty content' ) ;
}
return false ;
}
return true ;
}
class ComboMarkdownEditor {
constructor ( container , options = { } ) {
container . _giteaComboMarkdownEditor = this ;
this . options = options ;
this . container = container ;
}
async init ( ) {
2023-04-08 01:03:29 +08:00
this . prepareEasyMDEToolbarActions ( ) ;
2023-05-09 07:22:52 +09:00
this . setupContainer ( ) ;
2023-04-08 01:03:29 +08:00
this . setupTab ( ) ;
this . setupDropzone ( ) ;
this . setupTextarea ( ) ;
2023-05-09 07:22:52 +09:00
await this . switchToUserPreference ( ) ;
2023-04-08 01:03:29 +08:00
}
applyEditorHeights ( el , heights ) {
if ( ! heights ) return ;
if ( heights . minHeight ) el . style . minHeight = heights . minHeight ;
if ( heights . height ) el . style . height = heights . height ;
if ( heights . maxHeight ) el . style . maxHeight = heights . maxHeight ;
}
2023-05-09 07:22:52 +09:00
setupContainer ( ) {
initTextExpander ( this . container . querySelector ( 'text-expander' ) ) ;
this . container . addEventListener ( 'ce-editor-content-changed' , ( e ) => this . options ? . onContentChanged ? . ( this , e ) ) ;
}
2023-04-08 01:03:29 +08:00
setupTextarea ( ) {
2023-04-03 18:06:57 +08:00
this . textarea = this . container . querySelector ( '.markdown-text-editor' ) ;
this . textarea . _giteaComboMarkdownEditor = this ;
2023-04-08 01:03:29 +08:00
this . textarea . id = ` _combo_markdown_editor_ ${ String ( elementIdCounter ++ ) } ` ;
this . textarea . addEventListener ( 'input' , ( e ) => this . options ? . onContentChanged ? . ( this , e ) ) ;
this . applyEditorHeights ( this . textarea , this . options . editorHeights ) ;
this . textareaAutosize = autosize ( this . textarea , { viewportMarginBottom : 130 } ) ;
2023-04-03 18:06:57 +08:00
this . textareaMarkdownToolbar = this . container . querySelector ( 'markdown-toolbar' ) ;
this . textareaMarkdownToolbar . setAttribute ( 'for' , this . textarea . id ) ;
2023-04-11 16:36:18 +08:00
for ( const el of this . textareaMarkdownToolbar . querySelectorAll ( '.markdown-toolbar-button' ) ) {
// upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
el . setAttribute ( 'role' , 'button' ) ;
}
2023-04-13 21:05:06 +02:00
const monospaceButton = this . container . querySelector ( '.markdown-switch-monospace' ) ;
const monospaceEnabled = localStorage ? . getItem ( 'markdown-editor-monospace' ) === 'true' ;
const monospaceText = monospaceButton . getAttribute ( monospaceEnabled ? 'data-disable-text' : 'data-enable-text' ) ;
monospaceButton . setAttribute ( 'data-tooltip-content' , monospaceText ) ;
monospaceButton . setAttribute ( 'aria-checked' , String ( monospaceEnabled ) ) ;
monospaceButton ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
const enabled = localStorage ? . getItem ( 'markdown-editor-monospace' ) !== 'true' ;
localStorage . setItem ( 'markdown-editor-monospace' , String ( enabled ) ) ;
this . textarea . classList . toggle ( 'gt-mono' , enabled ) ;
const text = monospaceButton . getAttribute ( enabled ? 'data-disable-text' : 'data-enable-text' ) ;
monospaceButton . setAttribute ( 'data-tooltip-content' , text ) ;
monospaceButton . setAttribute ( 'aria-checked' , String ( enabled ) ) ;
} ) ;
const easymdeButton = this . container . querySelector ( '.markdown-switch-easymde' ) ;
easymdeButton ? . addEventListener ( 'click' , async ( e ) => {
2023-04-03 18:06:57 +08:00
e . preventDefault ( ) ;
2023-04-08 01:03:29 +08:00
this . userPreferredEditor = 'easymde' ;
2023-04-03 18:06:57 +08:00
await this . switchToEasyMDE ( ) ;
} ) ;
2023-04-08 01:03:29 +08:00
if ( this . dropzone ) {
initTextareaImagePaste ( this . textarea , this . dropzone ) ;
}
}
2023-04-03 18:06:57 +08:00
2023-04-08 01:03:29 +08:00
setupDropzone ( ) {
2023-04-03 18:06:57 +08:00
const dropzoneParentContainer = this . container . getAttribute ( 'data-dropzone-parent-container' ) ;
if ( dropzoneParentContainer ) {
this . dropzone = this . container . closest ( this . container . getAttribute ( 'data-dropzone-parent-container' ) ) ? . querySelector ( '.dropzone' ) ;
}
}
setupTab ( ) {
const $container = $ ( this . container ) ;
const $tabMenu = $container . find ( '.tabular.menu' ) ;
const $tabs = $tabMenu . find ( '> .item' ) ;
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const $tabEditor = $tabs . filter ( ` .item[data-tab-for="markdown-writer"] ` ) ;
const $tabPreviewer = $tabs . filter ( ` .item[data-tab-for="markdown-previewer"] ` ) ;
$tabEditor . attr ( 'data-tab' , ` markdown-writer- ${ elementIdCounter } ` ) ;
$tabPreviewer . attr ( 'data-tab' , ` markdown-previewer- ${ elementIdCounter } ` ) ;
const $panelEditor = $container . find ( '.ui.tab[data-tab-panel="markdown-writer"]' ) ;
const $panelPreviewer = $container . find ( '.ui.tab[data-tab-panel="markdown-previewer"]' ) ;
$panelEditor . attr ( 'data-tab' , ` markdown-writer- ${ elementIdCounter } ` ) ;
$panelPreviewer . attr ( 'data-tab' , ` markdown-previewer- ${ elementIdCounter } ` ) ;
elementIdCounter ++ ;
$tabs . tab ( ) ;
this . previewUrl = $tabPreviewer . attr ( 'data-preview-url' ) ;
this . previewContext = $tabPreviewer . attr ( 'data-preview-context' ) ;
this . previewMode = this . options . previewMode ? ? 'comment' ;
this . previewWiki = this . options . previewWiki ? ? false ;
$tabPreviewer . on ( 'click' , ( ) => {
$ . post ( this . previewUrl , {
_csrf : window . config . csrfToken ,
mode : this . previewMode ,
context : this . previewContext ,
text : this . value ( ) ,
wiki : this . previewWiki ,
} , ( data ) => {
2023-04-12 11:03:23 +08:00
renderPreviewPanelContent ( $panelPreviewer , data ) ;
2023-04-03 18:06:57 +08:00
} ) ;
} ) ;
}
prepareEasyMDEToolbarActions ( ) {
this . easyMDEToolbarDefault = [
2023-05-03 07:23:39 +02:00
'bold' , 'italic' , 'strikethrough' , '|' , 'heading-1' , 'heading-2' , 'heading-3' ,
'heading-bigger' , 'heading-smaller' , '|' , 'code' , 'quote' , '|' , 'gitea-checkbox-empty' ,
'gitea-checkbox-checked' , '|' , 'unordered-list' , 'ordered-list' , '|' , 'link' , 'image' ,
'table' , 'horizontal-rule' , '|' , 'gitea-switch-to-textarea' ,
2023-04-03 18:06:57 +08:00
] ;
}
2023-05-03 07:23:39 +02:00
parseEasyMDEToolbar ( EasyMDE , actions ) {
this . easyMDEToolbarActions = this . easyMDEToolbarActions || easyMDEToolbarActions ( EasyMDE , this ) ;
2023-04-03 18:06:57 +08:00
const processed = [ ] ;
for ( const action of actions ) {
2023-05-03 07:23:39 +02:00
const actionButton = this . easyMDEToolbarActions [ action ] ;
if ( ! actionButton ) throw new Error ( ` Unknown EasyMDE toolbar action ${ action } ` ) ;
processed . push ( actionButton ) ;
2023-04-03 18:06:57 +08:00
}
return processed ;
}
2023-05-09 07:22:52 +09:00
async switchToUserPreference ( ) {
if ( this . userPreferredEditor === 'easymde' ) {
await this . switchToEasyMDE ( ) ;
} else {
this . switchToTextarea ( ) ;
}
}
2023-04-08 01:03:29 +08:00
switchToTextarea ( ) {
2023-05-09 07:22:52 +09:00
if ( ! this . easyMDE ) return ;
2023-04-03 18:06:57 +08:00
showElem ( this . textareaMarkdownToolbar ) ;
if ( this . easyMDE ) {
this . easyMDE . toTextArea ( ) ;
this . easyMDE = null ;
}
}
async switchToEasyMDE ( ) {
2023-05-09 07:22:52 +09:00
if ( this . easyMDE ) return ;
2023-04-03 18:06:57 +08:00
// EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
const { default : EasyMDE } = await import ( /* webpackChunkName: "easymde" */ 'easymde' ) ;
const easyMDEOpt = {
autoDownloadFontAwesome : false ,
element : this . textarea ,
forceSync : true ,
renderingConfig : { singleLineBreaks : false } ,
indentWithTabs : false ,
tabSize : 4 ,
spellChecker : false ,
inputStyle : 'contenteditable' , // nativeSpellcheck requires contenteditable
nativeSpellcheck : true ,
... this . options . easyMDEOptions ,
} ;
2023-05-03 07:23:39 +02:00
easyMDEOpt . toolbar = this . parseEasyMDEToolbar ( EasyMDE , easyMDEOpt . toolbar ? ? this . easyMDEToolbarDefault ) ;
2023-04-03 18:06:57 +08:00
this . easyMDE = new EasyMDE ( easyMDEOpt ) ;
this . easyMDE . codemirror . on ( 'change' , ( ... args ) => { this . options ? . onContentChanged ? . ( this , ... args ) } ) ;
this . easyMDE . codemirror . setOption ( 'extraKeys' , {
'Cmd-Enter' : ( cm ) => handleGlobalEnterQuickSubmit ( cm . getTextArea ( ) ) ,
'Ctrl-Enter' : ( cm ) => handleGlobalEnterQuickSubmit ( cm . getTextArea ( ) ) ,
Enter : ( cm ) => {
const tributeContainer = document . querySelector ( '.tribute-container' ) ;
if ( ! tributeContainer || tributeContainer . style . display === 'none' ) {
cm . execCommand ( 'newlineAndIndent' ) ;
}
} ,
Up : ( cm ) => {
const tributeContainer = document . querySelector ( '.tribute-container' ) ;
if ( ! tributeContainer || tributeContainer . style . display === 'none' ) {
return cm . execCommand ( 'goLineUp' ) ;
}
} ,
Down : ( cm ) => {
const tributeContainer = document . querySelector ( '.tribute-container' ) ;
if ( ! tributeContainer || tributeContainer . style . display === 'none' ) {
return cm . execCommand ( 'goLineDown' ) ;
}
} ,
} ) ;
2023-04-08 01:03:29 +08:00
this . applyEditorHeights ( this . container . querySelector ( '.CodeMirror-scroll' ) , this . options . editorHeights ) ;
2023-04-03 18:06:57 +08:00
await attachTribute ( this . easyMDE . codemirror . getInputField ( ) , { mentions : true , emoji : true } ) ;
initEasyMDEImagePaste ( this . easyMDE , this . dropzone ) ;
hideElem ( this . textareaMarkdownToolbar ) ;
}
value ( v = undefined ) {
if ( v === undefined ) {
if ( this . easyMDE ) {
return this . easyMDE . value ( ) ;
}
return this . textarea . value ;
}
if ( this . easyMDE ) {
this . easyMDE . value ( v ) ;
} else {
this . textarea . value = v ;
}
2023-04-08 01:03:29 +08:00
this . textareaAutosize . resizeToFit ( ) ;
2023-04-03 18:06:57 +08:00
}
focus ( ) {
if ( this . easyMDE ) {
this . easyMDE . codemirror . focus ( ) ;
} else {
this . textarea . focus ( ) ;
}
}
moveCursorToEnd ( ) {
this . textarea . focus ( ) ;
this . textarea . setSelectionRange ( this . textarea . value . length , this . textarea . value . length ) ;
if ( this . easyMDE ) {
this . easyMDE . codemirror . focus ( ) ;
this . easyMDE . codemirror . setCursor ( this . easyMDE . codemirror . lineCount ( ) , 0 ) ;
}
}
2023-04-08 01:03:29 +08:00
get userPreferredEditor ( ) {
return window . localStorage . getItem ( ` markdown-editor- ${ this . options . useScene ? ? 'default' } ` ) ;
}
set userPreferredEditor ( s ) {
window . localStorage . setItem ( ` markdown-editor- ${ this . options . useScene ? ? 'default' } ` , s ) ;
}
2023-04-03 18:06:57 +08:00
}
export function getComboMarkdownEditor ( el ) {
if ( el instanceof $ ) el = el [ 0 ] ;
return el ? . _giteaComboMarkdownEditor ;
}
export async function initComboMarkdownEditor ( container , options = { } ) {
if ( container instanceof $ ) {
if ( container . length !== 1 ) {
throw new Error ( 'initComboMarkdownEditor: container must be a single element' ) ;
}
container = container [ 0 ] ;
}
if ( ! container ) {
throw new Error ( 'initComboMarkdownEditor: container is null' ) ;
}
const editor = new ComboMarkdownEditor ( container , options ) ;
await editor . init ( ) ;
return editor ;
}