2023-04-03 12:06:57 +02:00
import '@github/markdown-toolbar-element' ;
2023-04-09 18:18:45 +02:00
import '@github/text-expander-element' ;
2023-04-07 19:03:29 +02:00
import $ from 'jquery' ;
2023-04-03 12:06:57 +02:00
import { attachTribute } from '../tribute.js' ;
2024-11-01 17:05:48 +01:00
import { hideElem , showElem , autosize , isElemVisible , replaceTextareaSelection } from '../../utils/dom.js' ;
2024-03-08 16:15:58 +01:00
import { initEasyMDEPaste , initTextareaPaste } from './Paste.js' ;
2023-04-03 12:06:57 +02:00
import { handleGlobalEnterQuickSubmit } from './QuickSubmit.js' ;
2023-04-12 05:03:23 +02:00
import { renderPreviewPanelContent } from '../repo-editor.js' ;
2023-05-03 07:23:39 +02:00
import { easyMDEToolbarActions } from './EasyMDEToolbarActions.js' ;
2023-05-09 00:22:52 +02:00
import { initTextExpander } from './TextExpander.js' ;
2023-06-27 04:45:24 +02:00
import { showErrorToast } from '../../modules/toast.js' ;
2024-02-25 05:42:29 +01:00
import { POST } from '../../modules/fetch.js' ;
2023-04-03 12:06:57 +02:00
let elementIdCounter = 0 ;
/ * *
* validate if the given textarea is non - empty .
2024-02-20 11:37:37 +01:00
* @ param { HTMLElement } textarea - The textarea element to be validated .
2023-04-03 12:06:57 +02:00
* @ returns { boolean } returns true if validation succeeded .
* /
2024-02-20 11:37:37 +01:00
export function validateTextareaNonEmpty ( textarea ) {
2023-04-03 12:06:57 +02: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 11:37:37 +01:00
if ( ! textarea . value ) {
if ( isElemVisible ( textarea ) ) {
textarea . required = true ;
const form = textarea . closest ( 'form' ) ;
form ? . reportValidity ( ) ;
2023-04-03 12:06:57 +02: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 12:06:57 +02:00
}
return false ;
}
return true ;
}
class ComboMarkdownEditor {
constructor ( container , options = { } ) {
container . _giteaComboMarkdownEditor = this ;
this . options = options ;
this . container = container ;
}
async init ( ) {
2023-04-07 19:03:29 +02:00
this . prepareEasyMDEToolbarActions ( ) ;
2023-05-09 00:22:52 +02:00
this . setupContainer ( ) ;
2023-04-07 19:03:29 +02:00
this . setupTab ( ) ;
this . setupDropzone ( ) ;
this . setupTextarea ( ) ;
2024-11-01 17:05:48 +01:00
this . setupTableInserter ( ) ;
2025-02-25 21:40:16 +01:00
this . setupLinkInserter ( ) ;
2023-04-07 19:03:29 +02:00
2023-05-09 00:22:52 +02:00
await this . switchToUserPreference ( ) ;
2024-11-01 17:05:48 +01:00
elementIdCounter ++ ;
2023-04-07 19:03:29 +02: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 00:22:52 +02:00
setupContainer ( ) {
initTextExpander ( this . container . querySelector ( 'text-expander' ) ) ;
this . container . addEventListener ( 'ce-editor-content-changed' , ( e ) => this . options ? . onContentChanged ? . ( this , e ) ) ;
}
2023-04-07 19:03:29 +02:00
setupTextarea ( ) {
2023-04-03 12:06:57 +02:00
this . textarea = this . container . querySelector ( '.markdown-text-editor' ) ;
this . textarea . _giteaComboMarkdownEditor = this ;
2024-11-01 17:05:48 +01:00
this . textarea . id = ` _combo_markdown_editor_ ${ elementIdCounter } ` ;
2023-04-07 19:03:29 +02:00
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-07 19:03:29 +02:00
2023-04-03 12:06:57 +02:00
this . textareaMarkdownToolbar = this . container . querySelector ( 'markdown-toolbar' ) ;
this . textareaMarkdownToolbar . setAttribute ( 'for' , this . textarea . id ) ;
2023-04-11 10:36:18 +02: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 13:31:48 +02: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 10:36:18 +02:00
}
2024-06-30 15:03:32 +02:00
this . textareaMarkdownToolbar . querySelector ( 'button[data-md-action="indent"]' ) ? . addEventListener ( 'click' , ( ) => {
this . indentSelection ( false ) ;
2024-06-15 15:40:31 +02:00
} ) ;
2024-06-30 15:03:32 +02:00
this . textareaMarkdownToolbar . querySelector ( 'button[data-md-action="unindent"]' ) ? . addEventListener ( 'click' , ( ) => {
this . indentSelection ( true ) ;
2024-06-15 15:40:31 +02:00
} ) ;
2024-11-01 17:05:48 +01:00
this . textareaMarkdownToolbar . querySelector ( 'button[data-md-action="new-table"]' ) ? . setAttribute ( 'data-modal' , ` div[data-markdown-table-modal-id=" ${ elementIdCounter } "] ` ) ;
2025-02-25 21:40:16 +01:00
this . textareaMarkdownToolbar . querySelector ( 'button[data-md-action="new-link"]' ) ? . setAttribute ( 'data-modal' , ` div[data-markdown-link-modal-id=" ${ elementIdCounter } "] ` ) ;
2024-06-30 15:03:32 +02:00
2024-03-08 16:15:58 +01:00
this . textarea . addEventListener ( 'keydown' , ( e ) => {
if ( e . shiftKey ) {
e . target . _shiftDown = true ;
}
2024-06-30 15:03:32 +02:00
if ( e . key === 'Enter' && ! e . shiftKey && ! e . ctrlKey && ! e . altKey ) {
2025-01-17 18:22:43 +01:00
// Prevent special line break handling if currently a text expander popup is open
if ( this . textarea . hasAttribute ( 'aria-expanded' ) ) return ;
2024-06-30 15:03:32 +02:00
if ( ! this . breakLine ( ) ) return ; // Nothing changed, let the default handler work.
2024-06-15 15:40:31 +02:00
this . options ? . onContentChanged ? . ( this , e ) ;
e . preventDefault ( ) ;
}
2024-03-08 16:15:58 +01:00
} ) ;
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 12:06:57 +02:00
e . preventDefault ( ) ;
2023-04-07 19:03:29 +02:00
this . userPreferredEditor = 'easymde' ;
2023-04-03 12:06:57 +02:00
await this . switchToEasyMDE ( ) ;
} ) ;
2023-04-07 19:03:29 +02:00
if ( this . dropzone ) {
2024-03-08 16:15:58 +01:00
initTextareaPaste ( this . textarea , this . dropzone ) ;
2023-04-07 19:03:29 +02:00
}
}
2023-04-03 12:06:57 +02:00
2023-04-07 19:03:29 +02:00
setupDropzone ( ) {
2023-04-03 12:06:57 +02: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 01:00:54 +01:00
const tabs = $container [ 0 ] . querySelectorAll ( '.tabular.menu > .item' ) ;
2023-04-03 12:06:57 +02: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 01:00:54 +01: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 12:06:57 +02:00
2024-03-25 01:00:54 +01:00
tabEditor . addEventListener ( 'click' , ( ) => {
2023-08-25 07:26:32 +02:00
requestAnimationFrame ( ( ) => {
this . focus ( ) ;
} ) ;
} ) ;
2024-03-25 01:00:54 +01:00
$ ( tabs ) . tab ( ) ;
2023-04-03 12:06:57 +02:00
2024-03-25 01:00:54 +01:00
this . previewUrl = tabPreviewer . getAttribute ( 'data-preview-url' ) ;
this . previewContext = tabPreviewer . getAttribute ( 'data-preview-context' ) ;
2023-04-03 12:06:57 +02:00
this . previewMode = this . options . previewMode ? ? 'comment' ;
this . previewWiki = this . options . previewWiki ? ? false ;
2024-03-25 01:00:54 +01:00
tabPreviewer . addEventListener ( 'click' , async ( ) => {
2024-02-25 05:42:29 +01: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 01:00:54 +01:00
renderPreviewPanelContent ( $ ( panelPreviewer ) , data ) ;
2023-04-03 12:06:57 +02:00
} ) ;
}
2024-11-01 17:05:48 +01:00
addNewTable ( event ) {
const elementId = event . target . getAttribute ( 'data-element-id' ) ;
const newTableModal = document . querySelector ( ` div[data-markdown-table-modal-id=" ${ elementId } "] ` ) ;
const form = newTableModal . querySelector ( 'div[data-selector-name="form"]' ) ;
2024-11-28 11:26:12 +01:00
// Validate input fields
2024-11-01 17:05:48 +01:00
for ( const currentInput of form . querySelectorAll ( 'input' ) ) {
if ( ! currentInput . checkValidity ( ) ) {
currentInput . reportValidity ( ) ;
return ;
}
}
let headerText = form . querySelector ( 'input[name="table-header"]' ) . value ;
let contentText = form . querySelector ( 'input[name="table-content"]' ) . value ;
const rowCount = parseInt ( form . querySelector ( 'input[name="table-rows"]' ) . value ) ;
const columnCount = parseInt ( form . querySelector ( 'input[name="table-columns"]' ) . value ) ;
headerText = headerText . padEnd ( contentText . length ) ;
contentText = contentText . padEnd ( headerText . length ) ;
let code = ` | ${ ( new Array ( columnCount ) ) . fill ( headerText ) . join ( ' | ' ) } | \n ` ;
code += ` |- ${ ( new Array ( columnCount ) ) . fill ( '-' . repeat ( headerText . length ) ) . join ( '-|-' ) } -| \n ` ;
for ( let i = 0 ; i < rowCount ; i ++ ) {
code += ` | ${ ( new Array ( columnCount ) ) . fill ( contentText ) . join ( ' | ' ) } | \n ` ;
}
replaceTextareaSelection ( document . getElementById ( ` _combo_markdown_editor_ ${ elementId } ` ) , code ) ;
// Close the modal
newTableModal . querySelector ( 'button[data-selector-name="cancel-button"]' ) . click ( ) ;
}
setupTableInserter ( ) {
const newTableModal = this . container . querySelector ( 'div[data-modal-name="new-markdown-table"]' ) ;
newTableModal . setAttribute ( 'data-markdown-table-modal-id' , elementIdCounter ) ;
const button = newTableModal . querySelector ( 'button[data-selector-name="ok-button"]' ) ;
button . setAttribute ( 'data-element-id' , elementIdCounter ) ;
button . addEventListener ( 'click' , this . addNewTable ) ;
}
2025-02-25 21:40:16 +01:00
addNewLink ( event ) {
const elementId = event . target . getAttribute ( 'data-element-id' ) ;
const newLinkModal = document . querySelector ( ` div[data-markdown-link-modal-id=" ${ elementId } "] ` ) ;
const form = newLinkModal . querySelector ( 'div[data-selector-name="form"]' ) ;
// Validate input fields
for ( const currentInput of form . querySelectorAll ( 'input' ) ) {
if ( ! currentInput . checkValidity ( ) ) {
currentInput . reportValidity ( ) ;
return ;
}
}
const url = form . querySelector ( 'input[name="link-url"]' ) . value ;
const description = form . querySelector ( 'input[name="link-description"]' ) . value ;
const code = ` [ ${ description } ]( ${ url } ) ` ;
replaceTextareaSelection ( document . getElementById ( ` _combo_markdown_editor_ ${ elementId } ` ) , code ) ;
// Close the modal then clear its fields in case the user wants to add another one.
newLinkModal . querySelector ( 'button[data-selector-name="cancel-button"]' ) . click ( ) ;
form . querySelector ( 'input[name="link-url"]' ) . value = '' ;
form . querySelector ( 'input[name="link-description"]' ) . value = '' ;
}
setupLinkInserter ( ) {
const newLinkModal = this . container . querySelector ( 'div[data-modal-name="new-markdown-link"]' ) ;
newLinkModal . setAttribute ( 'data-markdown-link-modal-id' , elementIdCounter ) ;
const textarea = document . getElementById ( ` _combo_markdown_editor_ ${ elementIdCounter } ` ) ;
$ ( newLinkModal ) . modal ( {
// Pre-fill the description field from the selection to create behavior similar
// to pasting an URL over selected text.
onShow : ( ) => {
const start = textarea . selectionStart ;
const end = textarea . selectionEnd ;
if ( start !== end ) {
const selection = textarea . value . slice ( start ? ? undefined , end ? ? undefined ) ;
newLinkModal . querySelector ( 'input[name="link-description"]' ) . value = selection ;
} else {
newLinkModal . querySelector ( 'input[name="link-description"]' ) . value = '' ;
}
} ,
} ) ;
const button = newLinkModal . querySelector ( 'button[data-selector-name="ok-button"]' ) ;
button . setAttribute ( 'data-element-id' , elementIdCounter ) ;
button . addEventListener ( 'click' , this . addNewLink ) ;
}
2023-04-03 12:06:57 +02: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 12:06:57 +02:00
] ;
}
2023-05-03 07:23:39 +02:00
parseEasyMDEToolbar ( EasyMDE , actions ) {
this . easyMDEToolbarActions = this . easyMDEToolbarActions || easyMDEToolbarActions ( EasyMDE , this ) ;
2023-04-03 12:06:57 +02: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 12:06:57 +02:00
}
return processed ;
}
2023-05-09 00:22:52 +02:00
async switchToUserPreference ( ) {
if ( this . userPreferredEditor === 'easymde' ) {
await this . switchToEasyMDE ( ) ;
} else {
this . switchToTextarea ( ) ;
}
}
2023-04-07 19:03:29 +02:00
switchToTextarea ( ) {
2023-05-09 00:22:52 +02:00
if ( ! this . easyMDE ) return ;
2023-04-03 12:06:57 +02:00
showElem ( this . textareaMarkdownToolbar ) ;
if ( this . easyMDE ) {
this . easyMDE . toTextArea ( ) ;
this . easyMDE = null ;
}
}
async switchToEasyMDE ( ) {
2023-05-09 00:22:52 +02:00
if ( this . easyMDE ) return ;
2023-04-03 12:06:57 +02: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 12:06:57 +02: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-07 19:03:29 +02:00
this . applyEditorHeights ( this . container . querySelector ( '.CodeMirror-scroll' ) , this . options . editorHeights ) ;
2023-04-03 12:06:57 +02: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 12:06:57 +02: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 12:06:57 +02: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-07 19:03:29 +02:00
2024-06-15 15:40:31 +02:00
indentSelection ( unindent ) {
// Indent with 4 spaces, unindent 4 spaces or fewer or a lost tab.
const indentPrefix = ' ' ;
const unindentRegex = /^( {1,4}|\t)/ ;
// Indent all lines that are included in the selection, partially or whole, while preserving the original selection at the end.
const lines = this . textarea . value . split ( '\n' ) ;
const changedLines = [ ] ;
// The current selection or cursor position.
const [ start , end ] = [ this . textarea . selectionStart , this . textarea . selectionEnd ] ;
// The range containing whole lines that will effectively be replaced.
let [ editStart , editEnd ] = [ start , end ] ;
// The range that needs to be re-selected to match previous selection.
let [ newStart , newEnd ] = [ start , end ] ;
// The start and end position of the current line (where end points to the newline or EOF)
let [ lineStart , lineEnd ] = [ 0 , 0 ] ;
for ( const line of lines ) {
lineEnd = lineStart + line . length + 1 ;
if ( lineEnd <= start ) {
lineStart = lineEnd ;
continue ;
}
const updated = unindent ? line . replace ( unindentRegex , '' ) : indentPrefix + line ;
changedLines . push ( updated ) ;
const move = updated . length - line . length ;
if ( start >= lineStart && start < lineEnd ) {
editStart = lineStart ;
newStart = Math . max ( start + move , lineStart ) ;
}
newEnd += move ;
editEnd = lineEnd - 1 ;
lineStart = lineEnd ;
if ( lineStart > end ) break ;
}
// Update changed lines whole.
const text = changedLines . join ( '\n' ) ;
2024-06-30 15:03:32 +02:00
this . textarea . focus ( ) ;
2024-06-15 15:40:31 +02:00
this . textarea . setSelectionRange ( editStart , editEnd ) ;
if ( ! document . execCommand ( 'insertText' , false , text ) ) {
// execCommand is deprecated, but setRangeText (and any other direct value modifications) erases the native undo history.
// So only fall back to it if execCommand fails.
this . textarea . setRangeText ( text ) ;
}
// Set selection to (effectively) be the same as before.
this . textarea . setSelectionRange ( newStart , Math . max ( newStart , newEnd ) ) ;
}
breakLine ( ) {
const [ start , end ] = [ this . textarea . selectionStart , this . textarea . selectionEnd ] ;
// Do nothing if a range is selected
if ( start !== end ) return false ;
const value = this . textarea . value ;
// Find the beginning of the current line.
const lineStart = Math . max ( 0 , value . lastIndexOf ( '\n' , start - 1 ) + 1 ) ;
// Find the end and extract the line.
2025-01-17 18:42:42 +01:00
const nextLF = value . indexOf ( '\n' , start ) ;
const lineEnd = nextLF === - 1 ? value . length : nextLF ;
const line = value . slice ( lineStart , lineEnd ) ;
2024-06-15 15:40:31 +02:00
// Match any whitespace at the start + any repeatable prefix + exactly one space after.
2025-01-17 18:42:42 +01:00
const prefix = line . match ( /^\s*((\d+)[.)]\s|[-*+]\s{1,4}\[[ x]\]\s?|[-*+]\s|(>\s?)+)?/ ) ;
2024-06-15 15:40:31 +02:00
// Defer to browser if we can't do anything more useful, or if the cursor is inside the prefix.
2025-01-17 18:42:42 +01:00
if ( ! prefix ) return false ;
const prefixLength = prefix [ 0 ] . length ;
if ( ! prefixLength || lineStart + prefixLength > start ) return false ;
// If the prefix is just indentation (which should always be an even number of spaces or tabs), check if a single whitespace is added to the end of the line.
// If this is the case do not leave the indentation and continue with the prefix.
if ( ( prefixLength % 2 === 1 && /^ +$/ . test ( prefix [ 0 ] ) ) || /^\t+ $/ . test ( prefix [ 0 ] ) ) {
prefix [ 0 ] = prefix [ 0 ] . slice ( 0 , prefixLength - 1 ) ;
} else if ( prefixLength === lineEnd - lineStart ) {
this . textarea . setSelectionRange ( lineStart , lineEnd ) ;
if ( ! document . execCommand ( 'insertText' , false , '\n' ) ) {
this . textarea . setRangeText ( '\n' ) ;
}
return true ;
}
2024-06-15 15:40:31 +02:00
// Insert newline + prefix.
let text = ` \n ${ prefix [ 0 ] } ` ;
// Increment a number if present. (perhaps detecting repeating 1. and not doing that then would be a good idea)
const num = text . match ( /\d+/ ) ;
if ( num ) text = text . replace ( num [ 0 ] , Number ( num [ 0 ] ) + 1 ) ;
text = text . replace ( '[x]' , '[ ]' ) ;
if ( ! document . execCommand ( 'insertText' , false , text ) ) {
this . textarea . setRangeText ( text ) ;
}
return true ;
}
2023-04-07 19:03:29 +02: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 12:06:57 +02: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 ;
}