Skip to content

Add some handy markdown editor features #32400

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion templates/shared/combomarkdowneditor.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand All @@ -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>
28 changes: 27 additions & 1 deletion web_src/css/editor/combomarkdowneditor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +41,24 @@
color: var(--color-primary);
}

.combo-markdown-editor md-header {
position: relative;
}
.combo-markdown-editor md-header::after {
font-size: 65%;
position: absolute;
top: 10px;
}
.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;
Expand Down
51 changes: 47 additions & 4 deletions web_src/js/features/comp/ComboMarkdownEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
Expand All @@ -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);
}
Expand Down Expand Up @@ -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();
}
Expand Down
27 changes: 27 additions & 0 deletions web_src/js/features/comp/EditorMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {initTextareaMarkdown} from './EditorMarkdown.ts';

test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
initTextareaMarkdown(textarea);

const testInput = function (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. [ ] ');
});
21 changes: 16 additions & 5 deletions web_src/js/features/comp/EditorMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Copy link
Member

@silverwind silverwind Nov 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it use replaceTextareaSelection? That method has the benefit that CTRL-Z works after it.

export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it can't at the moment.

I know the existence of replaceTextareaSelection but it needs some extra work to merge these two.

Copy link
Member

@silverwind silverwind Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only difference seems to be that this function re-selects the inserted text and focuses the textarea. The re-selection could be pictured in a option and the focus could be done after the function call.

replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string, {reselect: false}: {reselect: boolean})

triggerEditorContentChanged does the same as a change event, right?

Copy link
Contributor Author

@wxiaoguang wxiaoguang Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

triggerEditorContentChanged does the same as a change event, right?

No.


I do not think making more changes is in this PR's scope since it has been fully tested.

And this textareaInsertText is not a new function, it is just extracted from insertPlaceholder, nothing worse.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer if this at least does not break the textarea history. Does CTRL-Z work after inserting content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This textareaInsertText is not a new function, it is just extracted from insertPlaceholder, nothing worse.

There is always a chance to propose new improvements.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix it later.


function handleIndentSelection(textarea, e) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
Expand Down Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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);
Expand Down
11 changes: 2 additions & 9 deletions web_src/js/features/comp/EditorUpload.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion web_src/js/modules/tippy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading