From af28ce59b8695a8412632c50cf96fdd420215719 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 4 Nov 2024 18:14:36 +0800 Subject: [PATCH] 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 --- options/locale/locale_en-US.ini | 4 ++ templates/shared/combomarkdowneditor.tmpl | 15 +++++- web_src/css/editor/combomarkdowneditor.css | 28 +++++++++- web_src/css/modules/comment.css | 1 - .../js/features/comp/ComboMarkdownEditor.ts | 51 +++++++++++++++++-- .../js/features/comp/EditorMarkdown.test.ts | 27 ++++++++++ web_src/js/features/comp/EditorMarkdown.ts | 21 ++++++-- web_src/js/features/comp/EditorUpload.ts | 11 +--- web_src/js/modules/tippy.ts | 2 +- 9 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 web_src/js/features/comp/EditorMarkdown.test.ts diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 06bf57fc62..679e64b424 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -209,6 +209,10 @@ buttons.link.tooltip = Add a link buttons.list.unordered.tooltip = Add a bullet list buttons.list.ordered.tooltip = Add a numbered list buttons.list.task.tooltip = Add a list of tasks +buttons.table.add.tooltip = Add a table +buttons.table.add.insert = Add +buttons.table.rows = Rows +buttons.table.cols = Columns buttons.mention.tooltip = Mention a user or team buttons.ref.tooltip = Reference an issue or pull request buttons.switch_to_legacy.tooltip = Use the legacy editor instead diff --git a/templates/shared/combomarkdowneditor.tmpl b/templates/shared/combomarkdowneditor.tmpl index 0a01dd9b1d..6ee989d1d6 100644 --- a/templates/shared/combomarkdowneditor.tmpl +++ b/templates/shared/combomarkdowneditor.tmpl @@ -21,7 +21,11 @@ Template Attributes:
- {{svg "octicon-heading"}} + {{svg "octicon-heading"}} + {{svg "octicon-heading"}} + {{svg "octicon-heading"}} +
+
{{svg "octicon-bold"}} {{svg "octicon-italic"}}
@@ -34,6 +38,7 @@ Template Attributes: {{svg "octicon-list-unordered"}} {{svg "octicon-list-ordered"}} {{svg "octicon-tasklist"}} +
{{svg "octicon-mention"}} @@ -56,4 +61,12 @@ Template Attributes:
{{ctx.Locale.Tr "loading"}}
+
+
+ + x + + +
+
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('[name=rows]').value); + let cols = parseInt(addTablePanel.querySelector('[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(); const arrowSvg = ``; -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;