]> source.dussan.org Git - gitea.git/commitdiff
Add some handy markdown editor features (#32400)
authorwxiaoguang <wxiaoguang@gmail.com>
Mon, 4 Nov 2024 10:14:36 +0000 (18:14 +0800)
committerGitHub <noreply@github.com>
Mon, 4 Nov 2024 10:14:36 +0000 (10:14 +0000)
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
templates/shared/combomarkdowneditor.tmpl
web_src/css/editor/combomarkdowneditor.css
web_src/css/modules/comment.css
web_src/js/features/comp/ComboMarkdownEditor.ts
web_src/js/features/comp/EditorMarkdown.test.ts [new file with mode: 0644]
web_src/js/features/comp/EditorMarkdown.ts
web_src/js/features/comp/EditorUpload.ts
web_src/js/modules/tippy.ts

index 06bf57fc62cf2a1ffc27af65893a42d8e2eec228..679e64b42415e83b08323e0db64cabc084d60b79 100644 (file)
@@ -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
index 0a01dd9b1d7708e79bb5ab99d686aa7ef5442b3b..6ee989d1d613d4259042c7956356f31238172c32 100644 (file)
@@ -21,7 +21,11 @@ Template Attributes:
        <div class="ui tab active" data-tab-panel="markdown-writer">
                <markdown-toolbar>
                        <div class="markdown-toolbar-group">
-                               <md-header class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
+                               <md-header class="markdown-toolbar-button" level="1" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
+                               <md-header class="markdown-toolbar-button" level="2" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
+                               <md-header class="markdown-toolbar-button" level="3" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
+                       </div>
+                       <div class="markdown-toolbar-group">
                                <md-bold class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.bold.tooltip"}}">{{svg "octicon-bold"}}</md-bold>
                                <md-italic class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.italic.tooltip"}}">{{svg "octicon-italic"}}</md-italic>
                        </div>
@@ -34,6 +38,7 @@ Template Attributes:
                                <md-unordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.unordered.tooltip"}}">{{svg "octicon-list-unordered"}}</md-unordered-list>
                                <md-ordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.ordered.tooltip"}}">{{svg "octicon-list-ordered"}}</md-ordered-list>
                                <md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list>
+                               <button class="markdown-toolbar-button markdown-button-table-add" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.add.tooltip"}}">{{svg "octicon-table"}}</button>
                        </div>
                        <div class="markdown-toolbar-group">
                                <md-mention class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.mention.tooltip"}}">{{svg "octicon-mention"}}</md-mention>
@@ -56,4 +61,12 @@ Template Attributes:
        <div class="ui tab markup" data-tab-panel="markdown-previewer">
                {{ctx.Locale.Tr "loading"}}
        </div>
+       <div class="markdown-add-table-panel tippy-target">
+               <div class="ui form tw-p-4 flex-text-block">
+                       <input type="number" name="rows" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.rows"}}">
+                       x
+                       <input type="number" name="cols" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.cols"}}">
+                       <button class="ui button primary" type="button">{{ctx.Locale.Tr "editor.buttons.table.add.insert"}}</button>
+               </div>
+       </div>
 </div>
index 8a2f4ea4160193161ebca9ff2c297e6e4ad90b06..97a8b702279d413794b449c34dc062e309c9195c 100644 (file)
@@ -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 {
   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;
index cda16fdddcbcc823e7fa231605da2e6752630189..68306686ef2985fa92248d6d36f06a80568a5de1 100644 (file)
@@ -21,7 +21,6 @@
   padding: 0.5em 0 0;
   border: none;
   border-top: none;
-  line-height: 1.2;
 }
 
 .edit-content-zone .comment {
index d0e122c54ab6ba2039f530bbe3cae3ddfcb2a224..576c1bccd697d4c8ae50d9045b8757c4e9a2776a 100644 (file)
@@ -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 (file)
index 0000000..acd496b
--- /dev/null
@@ -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. [ ] ');
+});
index deee561dabaa9c96b414cbd8cb5eba394178ec1f..2af003ccb0a438fb8d7c2e020bcb4d40ec6932aa 100644 (file)
@@ -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);
index 582639a817bf35f36ae05339aed429b147db52fa..b1f49cbe92667a579f6fc75edc52c7dabe27d51a 100644 (file)
@@ -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) {
index 375d816c6bc2e27512ab26c140dd85e01c29420c..d75015f69efcf94612be77fcec1c73047f45a58b 100644 (file)
@@ -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;