aboutsummaryrefslogtreecommitdiffstats
path: root/web_src
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2024-11-04 18:14:36 +0800
committerGitHub <noreply@github.com>2024-11-04 10:14:36 +0000
commitaf28ce59b8695a8412632c50cf96fdd420215719 (patch)
treef3f5d9c70f018883e66c5d34dc9904e974066705 /web_src
parent54146e62c0b65a941017983f88f7715e6f35c7b1 (diff)
downloadgitea-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.css28
-rw-r--r--web_src/css/modules/comment.css1
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.ts51
-rw-r--r--web_src/js/features/comp/EditorMarkdown.test.ts27
-rw-r--r--web_src/js/features/comp/EditorMarkdown.ts21
-rw-r--r--web_src/js/features/comp/EditorUpload.ts11
-rw-r--r--web_src/js/modules/tippy.ts2
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;