Skip to content

Commit af28ce5

Browse files
authored
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
1 parent 54146e6 commit af28ce5

File tree

9 files changed

+138
-22
lines changed

9 files changed

+138
-22
lines changed

options/locale/locale_en-US.ini

+4
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,10 @@ buttons.link.tooltip = Add a link
209209
buttons.list.unordered.tooltip = Add a bullet list
210210
buttons.list.ordered.tooltip = Add a numbered list
211211
buttons.list.task.tooltip = Add a list of tasks
212+
buttons.table.add.tooltip = Add a table
213+
buttons.table.add.insert = Add
214+
buttons.table.rows = Rows
215+
buttons.table.cols = Columns
212216
buttons.mention.tooltip = Mention a user or team
213217
buttons.ref.tooltip = Reference an issue or pull request
214218
buttons.switch_to_legacy.tooltip = Use the legacy editor instead

templates/shared/combomarkdowneditor.tmpl

+14-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ Template Attributes:
2121
<div class="ui tab active" data-tab-panel="markdown-writer">
2222
<markdown-toolbar>
2323
<div class="markdown-toolbar-group">
24-
<md-header class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
24+
<md-header class="markdown-toolbar-button" level="1" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
25+
<md-header class="markdown-toolbar-button" level="2" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
26+
<md-header class="markdown-toolbar-button" level="3" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
27+
</div>
28+
<div class="markdown-toolbar-group">
2529
<md-bold class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.bold.tooltip"}}">{{svg "octicon-bold"}}</md-bold>
2630
<md-italic class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.italic.tooltip"}}">{{svg "octicon-italic"}}</md-italic>
2731
</div>
@@ -34,6 +38,7 @@ Template Attributes:
3438
<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>
3539
<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>
3640
<md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list>
41+
<button class="markdown-toolbar-button markdown-button-table-add" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.add.tooltip"}}">{{svg "octicon-table"}}</button>
3742
</div>
3843
<div class="markdown-toolbar-group">
3944
<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:
5661
<div class="ui tab markup" data-tab-panel="markdown-previewer">
5762
{{ctx.Locale.Tr "loading"}}
5863
</div>
64+
<div class="markdown-add-table-panel tippy-target">
65+
<div class="ui form tw-p-4 flex-text-block">
66+
<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"}}">
67+
x
68+
<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"}}">
69+
<button class="ui button primary" type="button">{{ctx.Locale.Tr "editor.buttons.table.add.insert"}}</button>
70+
</div>
71+
</div>
5972
</div>

web_src/css/editor/combomarkdowneditor.css

+27-1
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,25 @@
77
display: flex;
88
align-items: center;
99
padding-bottom: 10px;
10-
gap: .5rem;
1110
flex-wrap: wrap;
1211
}
1312

1413
.combo-markdown-editor .markdown-toolbar-group {
1514
display: flex;
15+
border-left: 1px solid var(--color-secondary);
16+
padding: 0 0.5em;
1617
}
1718

19+
.combo-markdown-editor .markdown-toolbar-group:first-child {
20+
border-left: 0;
21+
padding-left: 0;
22+
}
1823
.combo-markdown-editor .markdown-toolbar-group:last-child {
1924
flex: 1;
2025
justify-content: flex-end;
26+
border-right: none;
27+
border-left: 0;
28+
padding-right: 0;
2129
}
2230

2331
.combo-markdown-editor .markdown-toolbar-button {
@@ -33,6 +41,24 @@
3341
color: var(--color-primary);
3442
}
3543

44+
.combo-markdown-editor md-header {
45+
position: relative;
46+
}
47+
.combo-markdown-editor md-header::after {
48+
font-size: 10px;
49+
position: absolute;
50+
top: 7px;
51+
}
52+
.combo-markdown-editor md-header[level="1"]::after {
53+
content: "1";
54+
}
55+
.combo-markdown-editor md-header[level="2"]::after {
56+
content: "2";
57+
}
58+
.combo-markdown-editor md-header[level="3"]::after {
59+
content: "3";
60+
}
61+
3662
.ui.form .combo-markdown-editor textarea.markdown-text-editor,
3763
.combo-markdown-editor textarea.markdown-text-editor {
3864
display: block;

web_src/css/modules/comment.css

-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
padding: 0.5em 0 0;
2222
border: none;
2323
border-top: none;
24-
line-height: 1.2;
2524
}
2625

2726
.edit-content-zone .comment {

web_src/js/features/comp/ComboMarkdownEditor.ts

+47-4
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
1515
import {initTextExpander} from './TextExpander.ts';
1616
import {showErrorToast} from '../../modules/toast.ts';
1717
import {POST} from '../../modules/fetch.ts';
18-
import {EventEditorContentChanged, initTextareaMarkdown, triggerEditorContentChanged} from './EditorMarkdown.ts';
18+
import {
19+
EventEditorContentChanged,
20+
initTextareaMarkdown,
21+
textareaInsertText,
22+
triggerEditorContentChanged,
23+
} from './EditorMarkdown.ts';
1924
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
25+
import {createTippy} from '../../modules/tippy.ts';
2026

2127
let elementIdCounter = 0;
2228

@@ -122,8 +128,7 @@ export class ComboMarkdownEditor {
122128
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
123129
monospaceButton.setAttribute('data-tooltip-content', monospaceText);
124130
monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
125-
126-
monospaceButton?.addEventListener('click', (e) => {
131+
monospaceButton.addEventListener('click', (e) => {
127132
e.preventDefault();
128133
const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
129134
localStorage.setItem('markdown-editor-monospace', String(enabled));
@@ -134,12 +139,14 @@ export class ComboMarkdownEditor {
134139
});
135140

136141
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
137-
easymdeButton?.addEventListener('click', async (e) => {
142+
easymdeButton.addEventListener('click', async (e) => {
138143
e.preventDefault();
139144
this.userPreferredEditor = 'easymde';
140145
await this.switchToEasyMDE();
141146
});
142147

148+
this.initMarkdownButtonTableAdd();
149+
143150
initTextareaMarkdown(this.textarea);
144151
initTextareaEvents(this.textarea, this.dropzone);
145152
}
@@ -219,6 +226,42 @@ export class ComboMarkdownEditor {
219226
});
220227
}
221228

229+
generateMarkdownTable(rows: number, cols: number): string {
230+
const tableLines = [];
231+
tableLines.push(
232+
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
233+
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
234+
);
235+
for (let i = 0; i < rows; i++) {
236+
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
237+
}
238+
return tableLines.join('\n');
239+
}
240+
241+
initMarkdownButtonTableAdd() {
242+
const addTableButton = this.container.querySelector('.markdown-button-table-add');
243+
const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
244+
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
245+
const addTablePanelTippy = createTippy(addTablePanel, {
246+
content: addTablePanel,
247+
trigger: 'manual',
248+
placement: 'bottom',
249+
hideOnClick: true,
250+
interactive: true,
251+
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
252+
});
253+
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
254+
255+
addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
256+
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
257+
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
258+
rows = Math.max(1, Math.min(100, rows));
259+
cols = Math.max(1, Math.min(100, cols));
260+
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
261+
addTablePanelTippy.hide();
262+
});
263+
}
264+
222265
switchTabToEditor() {
223266
this.tabEditor.click();
224267
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {initTextareaMarkdown} from './EditorMarkdown.ts';
2+
3+
test('EditorMarkdown', () => {
4+
const textarea = document.createElement('textarea');
5+
initTextareaMarkdown(textarea);
6+
7+
const testInput = (value, expected) => {
8+
textarea.value = value;
9+
textarea.setSelectionRange(value.length, value.length);
10+
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
11+
textarea.dispatchEvent(e);
12+
if (!e.defaultPrevented) textarea.value += '\n';
13+
expect(textarea.value).toEqual(expected);
14+
};
15+
16+
testInput('-', '-\n');
17+
testInput('1.', '1.\n');
18+
19+
testInput('- ', '');
20+
testInput('1. ', '');
21+
22+
testInput('- x', '- x\n- ');
23+
testInput('- [ ]', '- [ ]\n- ');
24+
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
25+
testInput('* [x] foo', '* [x] foo\n* [ ] ');
26+
testInput('1. [x] foo', '1. [x] foo\n1. [ ] ');
27+
});

web_src/js/features/comp/EditorMarkdown.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ export function triggerEditorContentChanged(target) {
44
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
55
}
66

7+
export function textareaInsertText(textarea, value) {
8+
const startPos = textarea.selectionStart;
9+
const endPos = textarea.selectionEnd;
10+
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
11+
textarea.selectionStart = startPos;
12+
textarea.selectionEnd = startPos + value.length;
13+
textarea.focus();
14+
triggerEditorContentChanged(textarea);
15+
}
16+
717
function handleIndentSelection(textarea, e) {
818
const selStart = textarea.selectionStart;
919
const selEnd = textarea.selectionEnd;
@@ -46,7 +56,7 @@ function handleIndentSelection(textarea, e) {
4656
triggerEditorContentChanged(textarea);
4757
}
4858

49-
function handleNewline(textarea, e) {
59+
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
5060
const selStart = textarea.selectionStart;
5161
const selEnd = textarea.selectionEnd;
5262
if (selEnd !== selStart) return; // do not process when there is a selection
@@ -66,9 +76,9 @@ function handleNewline(textarea, e) {
6676
const indention = /^\s*/.exec(line)[0];
6777
line = line.slice(indention.length);
6878

69-
// parse the prefixes: "1. ", "- ", "* ", "[ ] ", "[x] "
79+
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
7080
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
71-
const prefixMatch = /^([0-9]+\.|[-*]|\[ \]|\[x\])\s/.exec(line);
81+
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line);
7282
let prefix = '';
7383
if (prefixMatch) {
7484
prefix = prefixMatch[0];
@@ -85,8 +95,9 @@ function handleNewline(textarea, e) {
8595
} else {
8696
// start a new line with the same indention and prefix
8797
let newPrefix = prefix;
88-
if (newPrefix === '[x]') newPrefix = '[ ]';
89-
if (/^\d+\./.test(newPrefix)) newPrefix = `1. `; // a simple approach, otherwise it needs to parse the lines after the current line
98+
// a simple approach, otherwise it needs to parse the lines after the current line
99+
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
100+
newPrefix = newPrefix.replace('[x]', '[ ]');
90101
const newLine = `\n${indention}${newPrefix}`;
91102
textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd);
92103
textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length);

web_src/js/features/comp/EditorUpload.ts

+2-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {imageInfo} from '../../utils/image.ts';
22
import {replaceTextareaSelection} from '../../utils/dom.ts';
33
import {isUrl} from '../../utils/url.ts';
4-
import {triggerEditorContentChanged} from './EditorMarkdown.ts';
4+
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
55
import {
66
DropzoneCustomEventRemovedFile,
77
DropzoneCustomEventUploadDone,
@@ -41,14 +41,7 @@ class TextareaEditor {
4141
}
4242

4343
insertPlaceholder(value) {
44-
const editor = this.editor;
45-
const startPos = editor.selectionStart;
46-
const endPos = editor.selectionEnd;
47-
editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos);
48-
editor.selectionStart = startPos;
49-
editor.selectionEnd = startPos + value.length;
50-
editor.focus();
51-
triggerEditorContentChanged(editor);
44+
textareaInsertText(this.editor, value);
5245
}
5346

5447
replacePlaceholder(oldVal, newVal) {

web_src/js/modules/tippy.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type TippyOpts = {
1111
const visibleInstances = new Set<Instance>();
1212
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>`;
1313

14-
export function createTippy(target: Element, opts: TippyOpts = {}) {
14+
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
1515
// the callback functions should be destructured from opts,
1616
// because we should use our own wrapper functions to handle them, do not let the user override them
1717
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;

0 commit comments

Comments
 (0)