Merge pull request #922 from umbraco/feature/markdown-editor-actions

Markdown Edtior Actions
This commit is contained in:
Jacob Overgaard
2023-10-09 16:17:01 +02:00
committed by GitHub
2 changed files with 505 additions and 8 deletions

View File

@@ -1,8 +1,15 @@
import { UmbCodeEditorElement, loadCodeEditor } from '@umbraco-cms/backoffice/code-editor';
import { css, html, customElement, query, property } from '@umbraco-cms/backoffice/external/lit';
import { KeyCode, KeyMod } from 'monaco-editor';
import { UmbCodeEditorController, UmbCodeEditorElement, loadCodeEditor } from '@umbraco-cms/backoffice/code-editor';
import { css, html, customElement, query, property, state } from '@umbraco-cms/backoffice/external/lit';
import { FormControlMixin } from '@umbraco-cms/backoffice/external/uui';
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
import {
UMB_LINK_PICKER_MODAL,
UMB_MEDIA_TREE_PICKER_MODAL,
UMB_MODAL_MANAGER_CONTEXT_TOKEN,
UmbModalManagerContext,
} from '@umbraco-cms/backoffice/modal';
/**
* @element umb-input-markdown
@@ -12,40 +19,514 @@ import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
@customElement('umb-input-markdown')
export class UmbInputMarkdownElement extends FormControlMixin(UmbLitElement) {
protected getFormElement() {
return undefined;
return this._codeEditor;
}
@property({ type: Boolean })
preview?: boolean;
#isCodeEditorReady = new UmbBooleanState(false);
isCodeEditorReady = this.#isCodeEditorReady.asObservable();
#editor?: UmbCodeEditorController;
@query('umb-code-editor')
_codeEditor?: UmbCodeEditorElement;
private _modalContext?: UmbModalManagerContext;
constructor() {
super();
this.#loadCodeEditor();
this.consumeContext(UMB_MODAL_MANAGER_CONTEXT_TOKEN, (instance) => {
this._modalContext = instance;
});
}
async #loadCodeEditor() {
try {
await loadCodeEditor();
this._codeEditor?.editor?.updateOptions({
this.#isCodeEditorReady.next(true);
this.#editor = this._codeEditor?.editor;
this.#editor?.updateOptions({
lineNumbers: false,
minimap: false,
folding: false,
});
this.#isCodeEditorReady.next(true);
this.#loadActions();
} catch (error) {
console.error(error);
}
}
async #loadActions() {
// TODO: Find a way to have "double" keybindings (ctrl+m+ctrl+c for `code`, rather than simple ctrl+c as its taken by OS to copy things)
// Going with the keybindings of a Markdown Shortcut plugin https://marketplace.visualstudio.com/items?itemName=robole.markdown-shortcuts#shortcuts or perhaps there are keybindings that would make more sense.
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H1',
id: 'h1',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit1],
run: () => this._insertAtCurrentLine('#'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H2',
id: 'h2',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit2],
run: () => this._insertAtCurrentLine('##'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H3',
id: 'h3',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit3],
run: () => this._insertAtCurrentLine('###'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H4',
id: 'h4',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit4],
run: () => this._insertAtCurrentLine('####'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H5',
id: 'h5',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit5],
run: () => this._insertAtCurrentLine('#####'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Heading H6',
id: 'h6',
keybindings: [KeyMod.CtrlCmd | KeyCode.Digit6],
run: () => this._insertAtCurrentLine('######'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Bold Text',
id: 'b',
keybindings: [KeyMod.CtrlCmd | KeyCode.KeyB],
run: () => this._insertBetweenSelection('**', '**', 'Your Bold Text'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Italic Text',
id: 'i',
keybindings: [KeyMod.CtrlCmd | KeyCode.KeyI],
run: () => this._insertBetweenSelection('*', '*', 'Your Italic Text'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Quote',
id: 'q',
keybindings: [KeyMod.CtrlCmd | KeyCode.KeyQ],
run: () => this._insertQuote(),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Ordered List',
id: 'ol',
keybindings: [KeyMod.CtrlCmd | KeyCode.KeyO],
run: () => this._insertAtCurrentLine('1. '),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Unordered List',
id: 'ul',
keybindings: [KeyMod.CtrlCmd | KeyCode.KeyU],
run: () => this._insertAtCurrentLine('- '),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Code',
id: 'code',
//keybindings: [KeyMod.CtrlCmd | KeyCode.KeyM | KeyMod.CtrlCmd | KeyCode.KeyC],
run: () => this._insertBetweenSelection('`', '`', 'Code'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Fenced Code',
id: 'fenced-code',
//keybindings: [KeyMod.CtrlCmd | KeyCode.KeyM | KeyMod.CtrlCmd | KeyCode.KeyF],
run: () => this._insertBetweenSelection('```', '```', 'Code'),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Line',
id: 'line',
//keybindings: [KeyMod.CtrlCmd | KeyCode.KeyM | KeyMod.CtrlCmd | KeyCode.KeyC],
run: () => this._insertLine(),
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Link',
id: 'link',
//keybindings: [KeyMod.CtrlCmd | KeyCode.KeyM | KeyMod.CtrlCmd | KeyCode.KeyC],
run: () => this._insertLink(),
// TODO: Open in modal
});
this.#editor?.monacoEditor?.addAction({
label: 'Add Image',
id: 'image',
//keybindings: [KeyMod.CtrlCmd | KeyCode.KeyM | KeyMod.CtrlCmd | KeyCode.KeyC],
run: () => this._insertMedia(),
// TODO: Open in modal
});
}
private _focusEditor(): void {
// If we press one of the action buttons manually (which is outside the editor), we need to focus the editor again.
this.#editor?.monacoEditor?.focus();
}
private _insertLink() {
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const selectedValue = this.#editor?.getValueInRange(selection);
this._focusEditor(); // Focus before opening modal
const modalContext = this._modalContext?.open(UMB_LINK_PICKER_MODAL, {
index: null,
link: { name: selectedValue },
config: {},
});
modalContext
?.onSubmit()
.then((data) => {
const name = this.localize.term('general_name');
const url = this.localize.term('general_url');
this.#editor?.monacoEditor?.executeEdits('', [
{ range: selection, text: `[${data.link.name || name}](${data.link.url || url})` },
]);
if (!data.link.name) {
this.#editor?.select({
startColumn: selection.startColumn + 1,
endColumn: selection.startColumn + 1 + name.length,
endLineNumber: selection.startLineNumber,
startLineNumber: selection.startLineNumber,
});
} else if (!data.link.url) {
this.#editor?.select({
startColumn: selection.startColumn + 3 + data.link.name.length,
endColumn: selection.startColumn + 3 + data.link.name.length + url.length,
endLineNumber: selection.startLineNumber,
startLineNumber: selection.startLineNumber,
});
}
})
.catch(() => undefined)
.finally(() => this._focusEditor());
}
private _insertMedia() {
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const alt = this.#editor?.getValueInRange(selection);
this._focusEditor(); // Focus before opening modal
const modalContext = this._modalContext?.open(UMB_MEDIA_TREE_PICKER_MODAL, {});
modalContext
?.onSubmit()
.then((data) => {
const imgUrl = data.selection[0];
this.#editor?.monacoEditor?.executeEdits('', [
//TODO: media url
{ range: selection, text: `[${alt || 'alt text'}](TODO: id-${imgUrl || this.localize.term('general_url')})` },
]);
if (!alt?.length) {
this.#editor?.select({
startColumn: selection.startColumn + 1,
endColumn: selection.startColumn + 9,
endLineNumber: selection.startLineNumber,
startLineNumber: selection.startLineNumber,
});
}
})
.catch(() => undefined)
.finally(() => this._focusEditor());
}
private _insertLine() {
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const endColumn = this.#editor?.monacoModel?.getLineMaxColumn(selection.endLineNumber) ?? 1;
if (endColumn === 1) {
this.#editor?.insertAtPosition('---\n', {
lineNumber: selection.endLineNumber,
column: 1,
});
} else {
this.#editor?.insertAtPosition('\n---\n', {
lineNumber: selection.endLineNumber,
column: endColumn,
});
}
this._focusEditor();
}
private _insertBetweenSelection(startValue: string, endValue: string, placeholder?: string) {
this._focusEditor();
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const selectedValue = this.#editor?.getValueInRange({
startLineNumber: selection.startLineNumber,
endLineNumber: selection.endLineNumber,
startColumn: selection.startColumn - startValue.length,
endColumn: selection.endColumn + endValue.length,
});
if (
selectedValue?.startsWith(startValue) &&
selectedValue.endsWith(endValue) &&
selectedValue.length > startValue.length + endValue.length
) {
//Cancel previous insert
this.#editor?.select({ ...selection, startColumn: selection.startColumn + startValue.length });
this.#editor?.monacoEditor?.executeEdits('', [
{
range: {
startColumn: selection.startColumn - startValue.length,
startLineNumber: selection.startLineNumber,
endColumn: selection.startColumn,
endLineNumber: selection.startLineNumber,
},
text: '',
},
{
range: {
startColumn: selection.endColumn + startValue.length,
startLineNumber: selection.startLineNumber,
endColumn: selection.endColumn,
endLineNumber: selection.startLineNumber,
},
text: '',
},
]);
} else {
// Insert
this.#editor?.insertAtPosition(startValue, {
lineNumber: selection.startLineNumber,
column: selection.startColumn,
});
this.#editor?.insertAtPosition(endValue, {
lineNumber: selection.endLineNumber,
column: selection.endColumn + startValue.length,
});
this.#editor?.select({
startLineNumber: selection.startLineNumber,
endLineNumber: selection.endLineNumber,
startColumn: selection.startColumn + startValue.length,
endColumn: selection.endColumn + startValue.length,
});
}
// if no text were selected when action fired
if (selection.startColumn === selection.endColumn && selection.startLineNumber === selection.endLineNumber) {
if (placeholder) {
this.#editor?.insertAtPosition(placeholder, {
lineNumber: selection.startLineNumber,
column: selection.startColumn + startValue.length,
});
}
this.#editor?.select({
startLineNumber: selection.startLineNumber,
endLineNumber: selection.endLineNumber,
startColumn: selection.startColumn + startValue.length,
endColumn: selection.startColumn + startValue.length + (placeholder?.length ?? 0),
});
}
}
private _insertAtCurrentLine(value: string) {
this._focusEditor();
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const previousLineValue = this.#editor?.getValueInRange({
...selection,
startLineNumber: selection.startLineNumber - 1,
});
const lineValue = this.#editor?.getValueInRange({ ...selection, startColumn: 1 });
// Regex: check if the line starts with a positive number followed by dot and a space
if (lineValue?.startsWith(value) || lineValue?.match(/^[1-9]\d*\.\s.*/)) {
// Cancel previous insert
this.#editor?.monacoEditor?.executeEdits('', [
{
range: {
startColumn: 1,
startLineNumber: selection.startLineNumber,
endColumn: 1 + value.length,
endLineNumber: selection.startLineNumber,
},
text: '',
},
]);
} else if (value.match(/^[1-9]\d*\.\s.*/) && previousLineValue?.match(/^[1-9]\d*\.\s.*/)) {
// Check if the PREVIOUS line starts with a positive number followed by dot and a space. If yes, get that number.
const previousNumber = parseInt(previousLineValue, 10);
this.#editor?.insertAtPosition(`${previousNumber + 1}. `, {
lineNumber: selection.startLineNumber,
column: 1,
});
} else {
// Insert
this.#editor?.insertAtPosition(value, {
lineNumber: selection.startLineNumber,
column: 1,
});
}
}
private _insertQuote() {
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
let index = selection.startLineNumber;
for (index; index <= selection.endLineNumber; index++) {
const line = this.#editor?.getValueInRange({
startLineNumber: index,
endLineNumber: index,
startColumn: 1,
endColumn: 3,
});
if (!line?.startsWith('> ')) {
this.#editor?.insertAtPosition('> ', {
lineNumber: index,
column: 1,
});
}
}
this._focusEditor();
}
private _renderBasicActions() {
return html`<div>
<uui-button
compact
look="secondary"
label="Heading"
title="Heading"
@click=${() => this.#editor?.monacoEditor?.getAction('h1')?.run()}>
H
</uui-button>
<uui-button
compact
look="secondary"
label="Bold"
title="Bold"
@click=${() => this.#editor?.monacoEditor?.getAction('b')?.run()}>
B
</uui-button>
<uui-button
compact
look="secondary"
label="Italic"
title="Italic"
@click=${() => this.#editor?.monacoEditor?.getAction('i')?.run()}>
I
</uui-button>
</div>
<div>
<uui-button
compact
look="secondary"
label="Quote"
title="Quote"
@click=${() => this.#editor?.monacoEditor?.getAction('q')?.run()}>
<uui-icon name="umb:quote"></uui-icon>
</uui-button>
<uui-button
compact
look="secondary"
label="Ordered List"
title="Ordered List"
@click=${() => this.#editor?.monacoEditor?.getAction('ol')?.run()}>
<uui-icon name="umb:ordered-list"></uui-icon>
</uui-button>
<uui-button
compact
look="secondary"
label="Unordered List"
title="Unordered List"
@click=${() => this.#editor?.monacoEditor?.getAction('ul')?.run()}>
<uui-icon name="umb:bulleted-list"></uui-icon>
</uui-button>
</div>
<div>
<uui-button
compact
look="secondary"
label="Fenced Code"
title="Fenced Code"
@click=${() => this.#editor?.monacoEditor?.getAction('fenced-code')?.run()}>
<uui-icon name="umb:code"></uui-icon>
</uui-button>
<uui-button
compact
look="secondary"
label="Line"
title="Line"
@click=${() => this.#editor?.monacoEditor?.getAction('line')?.run()}>
<uui-icon name="umb:width"></uui-icon>
</uui-button>
<uui-button
compact
look="secondary"
label="Link"
title="Link"
@click=${() => this.#editor?.monacoEditor?.getAction('link')?.run()}>
<uui-icon name="umb:link"></uui-icon>
</uui-button>
<uui-button
compact
look="secondary"
label="Image"
title="Image"
@click=${() => this.#editor?.monacoEditor?.getAction('image')?.run()}>
<uui-icon name="umb:picture"></uui-icon>
</uui-button>
</div>
<div>
<uui-button
compact
label="Press F1 for all actions"
title="Press F1 for all actions"
@click=${() => {
this._focusEditor();
this.#editor?.monacoEditor?.trigger('', 'editor.action.quickCommand', '');
}}>
<uui-key>F1</uui-key>
</uui-button>
</div>`;
}
onKeyPress(e: KeyboardEvent) {
if (e.key !== 'Enter') return;
//TODO: Tab does not seem to trigger keyboard events. We need to make some logic for ordered and unordered lists when tab is being used.
const selection = this.#editor?.getSelections()[0];
if (!selection) return;
const lineValue = this.#editor?.getValueInRange({ ...selection, startColumn: 1 }).trimStart();
if (!lineValue) return;
if (lineValue.startsWith('- ') && lineValue.length > 2) {
requestAnimationFrame(() => this.#editor?.insert('- '));
} else if (lineValue.match(/^[1-9]\d*\.\s.*/) && lineValue.length > 3) {
const previousNumber = parseInt(lineValue, 10);
requestAnimationFrame(() => this.#editor?.insert(`${previousNumber + 1}. `));
}
}
render() {
return html` <div id="actions"></div>
<umb-code-editor language="markdown" .code=${this.value as string}></umb-code-editor>
//TODO: Why is the theme dark in Backoffice, but light in Storybook?
return html` <div id="actions">${this._renderBasicActions()}</div>
<umb-code-editor
language="markdown"
.code=${this.value as string}
@keypress=${this.onKeyPress}
theme="umb-light"></umb-code-editor>
${this.renderPreview()}`;
}
@@ -63,6 +544,16 @@ export class UmbInputMarkdownElement extends FormControlMixin(UmbLitElement) {
#actions {
background-color: var(--uui-color-background-alt);
display: flex;
gap: var(--uui-size-6);
}
#actions div {
display: flex;
gap: var(--uui-size-1);
}
#actions div:last-child {
margin-left: auto;
}
umb-code-editor {
@@ -70,6 +561,10 @@ export class UmbInputMarkdownElement extends FormControlMixin(UmbLitElement) {
border-radius: var(--uui-border-radius);
border: 1px solid var(--uui-color-divider-emphasis);
}
uui-button {
width: 50px;
}
`,
];
}

View File

@@ -153,6 +153,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
return html`<uui-label for="search-input">Link to page</uui-label>
<uui-input id="search-input" placeholder="Type to search" label="Type to search"></uui-input>
<umb-tree
?multiple=${false}
alias="Umb.Tree.Documents"
@selected=${(event: CustomEvent) => this._handleSelectionChange(event, 'document')}
.selection=${[this._selectedKey ?? '']}
@@ -163,6 +164,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElement<UmbLinkPicker
<uui-label>Link to media</uui-label>
<umb-tree
?multiple=${false}
alias="Umb.Tree.Media"
@selected=${(event: CustomEvent) => this._handleSelectionChange(event, 'media')}
.selection=${[this._selectedKey ?? '']}