diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2024-11-04 18:14:36 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-04 10:14:36 +0000 |
commit | af28ce59b8695a8412632c50cf96fdd420215719 (patch) | |
tree | f3f5d9c70f018883e66c5d34dc9904e974066705 /web_src | |
parent | 54146e62c0b65a941017983f88f7715e6f35c7b1 (diff) | |
download | gitea-af28ce59b8695a8412632c50cf96fdd420215719.tar.gz gitea-af28ce59b8695a8412632c50cf96fdd420215719.zip |
Add some handy markdown editor features (#32400)
There were some missing features from EasyMDE:
1. H1 - H3 style
2. Auto add task list
3. Insert a table
And added some tests
Diffstat (limited to 'web_src')
-rw-r--r-- | web_src/css/editor/combomarkdowneditor.css | 28 | ||||
-rw-r--r-- | web_src/css/modules/comment.css | 1 | ||||
-rw-r--r-- | web_src/js/features/comp/ComboMarkdownEditor.ts | 51 | ||||
-rw-r--r-- | web_src/js/features/comp/EditorMarkdown.test.ts | 27 | ||||
-rw-r--r-- | web_src/js/features/comp/EditorMarkdown.ts | 21 | ||||
-rw-r--r-- | web_src/js/features/comp/EditorUpload.ts | 11 | ||||
-rw-r--r-- | web_src/js/modules/tippy.ts | 2 |
7 files changed, 120 insertions, 21 deletions
diff --git a/web_src/css/editor/combomarkdowneditor.css b/web_src/css/editor/combomarkdowneditor.css index 8a2f4ea416..97a8b70227 100644 --- a/web_src/css/editor/combomarkdowneditor.css +++ b/web_src/css/editor/combomarkdowneditor.css @@ -7,17 +7,25 @@ display: flex; align-items: center; padding-bottom: 10px; - gap: .5rem; flex-wrap: wrap; } .combo-markdown-editor .markdown-toolbar-group { display: flex; + border-left: 1px solid var(--color-secondary); + padding: 0 0.5em; } +.combo-markdown-editor .markdown-toolbar-group:first-child { + border-left: 0; + padding-left: 0; +} .combo-markdown-editor .markdown-toolbar-group:last-child { flex: 1; justify-content: flex-end; + border-right: none; + border-left: 0; + padding-right: 0; } .combo-markdown-editor .markdown-toolbar-button { @@ -33,6 +41,24 @@ color: var(--color-primary); } +.combo-markdown-editor md-header { + position: relative; +} +.combo-markdown-editor md-header::after { + font-size: 10px; + position: absolute; + top: 7px; +} +.combo-markdown-editor md-header[level="1"]::after { + content: "1"; +} +.combo-markdown-editor md-header[level="2"]::after { + content: "2"; +} +.combo-markdown-editor md-header[level="3"]::after { + content: "3"; +} + .ui.form .combo-markdown-editor textarea.markdown-text-editor, .combo-markdown-editor textarea.markdown-text-editor { display: block; diff --git a/web_src/css/modules/comment.css b/web_src/css/modules/comment.css index cda16fdddc..68306686ef 100644 --- a/web_src/css/modules/comment.css +++ b/web_src/css/modules/comment.css @@ -21,7 +21,6 @@ padding: 0.5em 0 0; border: none; border-top: none; - line-height: 1.2; } .edit-content-zone .comment { diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index d0e122c54a..576c1bccd6 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -15,8 +15,14 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts'; import {initTextExpander} from './TextExpander.ts'; import {showErrorToast} from '../../modules/toast.ts'; import {POST} from '../../modules/fetch.ts'; -import {EventEditorContentChanged, initTextareaMarkdown, triggerEditorContentChanged} from './EditorMarkdown.ts'; +import { + EventEditorContentChanged, + initTextareaMarkdown, + textareaInsertText, + triggerEditorContentChanged, +} from './EditorMarkdown.ts'; import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; +import {createTippy} from '../../modules/tippy.ts'; let elementIdCounter = 0; @@ -122,8 +128,7 @@ export class ComboMarkdownEditor { 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) => { + monospaceButton.addEventListener('click', (e) => { e.preventDefault(); const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true'; localStorage.setItem('markdown-editor-monospace', String(enabled)); @@ -134,12 +139,14 @@ export class ComboMarkdownEditor { }); const easymdeButton = this.container.querySelector('.markdown-switch-easymde'); - easymdeButton?.addEventListener('click', async (e) => { + easymdeButton.addEventListener('click', async (e) => { e.preventDefault(); this.userPreferredEditor = 'easymde'; await this.switchToEasyMDE(); }); + this.initMarkdownButtonTableAdd(); + initTextareaMarkdown(this.textarea); initTextareaEvents(this.textarea, this.dropzone); } @@ -219,6 +226,42 @@ export class ComboMarkdownEditor { }); } + generateMarkdownTable(rows: number, cols: number): string { + const tableLines = []; + tableLines.push( + `| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`, + `| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`, + ); + for (let i = 0; i < rows; i++) { + tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`); + } + return tableLines.join('\n'); + } + + initMarkdownButtonTableAdd() { + const addTableButton = this.container.querySelector('.markdown-button-table-add'); + const addTablePanel = this.container.querySelector('.markdown-add-table-panel'); + // here the tippy can't attach to the button because the button already owns a tippy for tooltip + const addTablePanelTippy = createTippy(addTablePanel, { + content: addTablePanel, + trigger: 'manual', + placement: 'bottom', + hideOnClick: true, + interactive: true, + getReferenceClientRect: () => addTableButton.getBoundingClientRect(), + }); + addTableButton.addEventListener('click', () => addTablePanelTippy.show()); + + addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => { + let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value); + let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value); + rows = Math.max(1, Math.min(100, rows)); + cols = Math.max(1, Math.min(100, cols)); + textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); + addTablePanelTippy.hide(); + }); + } + switchTabToEditor() { this.tabEditor.click(); } diff --git a/web_src/js/features/comp/EditorMarkdown.test.ts b/web_src/js/features/comp/EditorMarkdown.test.ts new file mode 100644 index 0000000000..acd496bed6 --- /dev/null +++ b/web_src/js/features/comp/EditorMarkdown.test.ts @@ -0,0 +1,27 @@ +import {initTextareaMarkdown} from './EditorMarkdown.ts'; + +test('EditorMarkdown', () => { + const textarea = document.createElement('textarea'); + initTextareaMarkdown(textarea); + + const testInput = (value, expected) => { + textarea.value = value; + textarea.setSelectionRange(value.length, value.length); + const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true}); + textarea.dispatchEvent(e); + if (!e.defaultPrevented) textarea.value += '\n'; + expect(textarea.value).toEqual(expected); + }; + + testInput('-', '-\n'); + testInput('1.', '1.\n'); + + testInput('- ', ''); + testInput('1. ', ''); + + testInput('- x', '- x\n- '); + testInput('- [ ]', '- [ ]\n- '); + testInput('- [ ] foo', '- [ ] foo\n- [ ] '); + testInput('* [x] foo', '* [x] foo\n* [ ] '); + testInput('1. [x] foo', '1. [x] foo\n1. [ ] '); +}); diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index deee561dab..2af003ccb0 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -4,6 +4,16 @@ export function triggerEditorContentChanged(target) { target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); } +export function textareaInsertText(textarea, value) { + const startPos = textarea.selectionStart; + const endPos = textarea.selectionEnd; + textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); + textarea.selectionStart = startPos; + textarea.selectionEnd = startPos + value.length; + textarea.focus(); + triggerEditorContentChanged(textarea); +} + function handleIndentSelection(textarea, e) { const selStart = textarea.selectionStart; const selEnd = textarea.selectionEnd; @@ -46,7 +56,7 @@ function handleIndentSelection(textarea, e) { triggerEditorContentChanged(textarea); } -function handleNewline(textarea, e) { +function handleNewline(textarea: HTMLTextAreaElement, e: Event) { const selStart = textarea.selectionStart; const selEnd = textarea.selectionEnd; if (selEnd !== selStart) return; // do not process when there is a selection @@ -66,9 +76,9 @@ function handleNewline(textarea, e) { const indention = /^\s*/.exec(line)[0]; line = line.slice(indention.length); - // parse the prefixes: "1. ", "- ", "* ", "[ ] ", "[x] " + // parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists // there must be a space after the prefix because none of "1.foo" / "-foo" is a list item - const prefixMatch = /^([0-9]+\.|[-*]|\[ \]|\[x\])\s/.exec(line); + const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line); let prefix = ''; if (prefixMatch) { prefix = prefixMatch[0]; @@ -85,8 +95,9 @@ function handleNewline(textarea, e) { } else { // start a new line with the same indention and prefix let newPrefix = prefix; - if (newPrefix === '[x]') newPrefix = '[ ]'; - if (/^\d+\./.test(newPrefix)) newPrefix = `1. `; // a simple approach, otherwise it needs to parse the lines after the current line + // a simple approach, otherwise it needs to parse the lines after the current line + if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`; + newPrefix = newPrefix.replace('[x]', '[ ]'); const newLine = `\n${indention}${newPrefix}`; textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd); textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length); diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index 582639a817..b1f49cbe92 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -1,7 +1,7 @@ import {imageInfo} from '../../utils/image.ts'; import {replaceTextareaSelection} from '../../utils/dom.ts'; import {isUrl} from '../../utils/url.ts'; -import {triggerEditorContentChanged} from './EditorMarkdown.ts'; +import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; import { DropzoneCustomEventRemovedFile, DropzoneCustomEventUploadDone, @@ -41,14 +41,7 @@ class TextareaEditor { } insertPlaceholder(value) { - const editor = this.editor; - const startPos = editor.selectionStart; - const endPos = editor.selectionEnd; - editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos); - editor.selectionStart = startPos; - editor.selectionEnd = startPos + value.length; - editor.focus(); - triggerEditorContentChanged(editor); + textareaInsertText(this.editor, value); } replacePlaceholder(oldVal, newVal) { diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 375d816c6b..d75015f69e 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -11,7 +11,7 @@ type TippyOpts = { const visibleInstances = new Set<Instance>(); const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; -export function createTippy(target: Element, opts: TippyOpts = {}) { +export function createTippy(target: Element, opts: TippyOpts = {}): Instance { // the callback functions should be destructured from opts, // because we should use our own wrapper functions to handle them, do not let the user override them const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts; |