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' ;
2024-02-20 12:37:37 +02:00
import { hideElem , showElem , autosize , isElemVisible } from '../../utils/dom.js' ;
2024-03-08 16:15:58 +01:00
import { initEasyMDEPaste , initTextareaPaste } from './Paste.js' ;
2023-04-03 18:06:57 +08:00
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-06-27 04:45:24 +02:00
import { showErrorToast } from '../../modules/toast.js' ;
2024-02-25 06:42:29 +02:00
import { POST } from '../../modules/fetch.js' ;
2023-04-03 18:06:57 +08:00
let elementIdCounter = 0 ;
/ * *
* validate if the given textarea is non - empty .
2024-02-20 12:37:37 +02:00
* @ param { HTMLElement } textarea - The textarea element to be validated .
2023-04-03 18:06:57 +08:00
* @ returns { boolean } returns true if validation succeeded .
* /
2024-02-20 12:37:37 +02:00
export function validateTextareaNonEmpty ( textarea ) {
2023-04-03 18:06:57 +08:00
// 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.
2024-02-20 12:37:37 +02:00
if ( ! textarea . value ) {
if ( isElemVisible ( textarea ) ) {
textarea . required = true ;
const form = textarea . closest ( 'form' ) ;
form ? . reportValidity ( ) ;
2023-04-03 18:06:57 +08:00
} else {
// The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
2023-06-27 04:45:24 +02:00
showErrorToast ( 'Require non-empty content' ) ;
2023-04-03 18:06:57 +08:00
}
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 ) ;
2023-07-31 00:11:15 +02:00
if ( this . textarea . getAttribute ( 'data-disable-autosize' ) !== 'true' ) {
this . textareaAutosize = autosize ( this . textarea , { viewportMarginBottom : 130 } ) ;
}
2023-04-08 01:03:29 +08:00
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-08-15 19:31:48 +08:00
// the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
if ( el . nodeName === 'BUTTON' && ! el . getAttribute ( 'type' ) ) el . setAttribute ( 'type' , 'button' ) ;
2023-04-11 16:36:18 +08:00
}
2023-04-13 21:05:06 +02:00
2024-03-08 16:15:58 +01:00
this . textarea . addEventListener ( 'keydown' , ( e ) => {
if ( e . shiftKey ) {
e . target . _shiftDown = true ;
}
} ) ;
this . textarea . addEventListener ( 'keyup' , ( e ) => {
if ( ! e . shiftKey ) {
e . target . _shiftDown = false ;
}
} ) ;
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 ) ) ;
2024-03-28 09:31:07 +01:00
this . textarea . classList . toggle ( 'tw-font-mono' , enabled ) ;
2023-04-13 21:05:06 +02:00
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 ) {
2024-03-08 16:15:58 +01:00
initTextareaPaste ( this . textarea , this . dropzone ) ;
2023-04-08 01:03:29 +08:00
}
}
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 ) ;
2024-03-25 02:00:54 +02:00
const tabs = $container [ 0 ] . querySelectorAll ( '.tabular.menu > .item' ) ;
2023-04-03 18:06:57 +08:00
// 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.
2024-03-25 02:00:54 +02:00
const tabEditor = Array . from ( tabs ) . find ( ( tab ) => tab . getAttribute ( 'data-tab-for' ) === 'markdown-writer' ) ;
const tabPreviewer = Array . from ( tabs ) . find ( ( tab ) => tab . getAttribute ( 'data-tab-for' ) === 'markdown-previewer' ) ;
tabEditor . setAttribute ( 'data-tab' , ` markdown-writer- ${ elementIdCounter } ` ) ;
tabPreviewer . setAttribute ( 'data-tab' , ` markdown-previewer- ${ elementIdCounter } ` ) ;
const panelEditor = $container [ 0 ] . querySelector ( '.ui.tab[data-tab-panel="markdown-writer"]' ) ;
const panelPreviewer = $container [ 0 ] . querySelector ( '.ui.tab[data-tab-panel="markdown-previewer"]' ) ;
panelEditor . setAttribute ( 'data-tab' , ` markdown-writer- ${ elementIdCounter } ` ) ;
panelPreviewer . setAttribute ( 'data-tab' , ` markdown-previewer- ${ elementIdCounter } ` ) ;
2023-04-03 18:06:57 +08:00
elementIdCounter ++ ;
2024-03-25 02:00:54 +02:00
tabEditor . addEventListener ( 'click' , ( ) => {
2023-08-25 07:26:32 +02:00
requestAnimationFrame ( ( ) => {
this . focus ( ) ;
} ) ;
} ) ;
2024-03-25 02:00:54 +02:00
$ ( tabs ) . tab ( ) ;
2023-04-03 18:06:57 +08:00
2024-03-25 02:00:54 +02:00
this . previewUrl = tabPreviewer . getAttribute ( 'data-preview-url' ) ;
this . previewContext = tabPreviewer . getAttribute ( 'data-preview-context' ) ;
2023-04-03 18:06:57 +08:00
this . previewMode = this . options . previewMode ? ? 'comment' ;
this . previewWiki = this . options . previewWiki ? ? false ;
2024-03-25 02:00:54 +02:00
tabPreviewer . addEventListener ( 'click' , async ( ) => {
2024-02-25 06:42:29 +02:00
const formData = new FormData ( ) ;
formData . append ( 'mode' , this . previewMode ) ;
formData . append ( 'context' , this . previewContext ) ;
formData . append ( 'text' , this . value ( ) ) ;
formData . append ( 'wiki' , this . previewWiki ) ;
const response = await POST ( this . previewUrl , { data : formData } ) ;
const data = await response . text ( ) ;
2024-03-25 02:00:54 +02: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 } ) ;
2024-03-08 16:15:58 +01:00
initEasyMDEPaste ( this . easyMDE , this . dropzone ) ;
2023-04-03 18:06:57 +08:00
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-07-31 00:11:15 +02: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 ;
}