diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-markdown-editor/input-markdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-markdown-editor/input-markdown.element.ts index e9202df63b..23d31e1088 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/input-markdown-editor/input-markdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/input-markdown-editor/input-markdown.element.ts @@ -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`
+ this.#editor?.monacoEditor?.getAction('h1')?.run()}> + H + + this.#editor?.monacoEditor?.getAction('b')?.run()}> + B + + this.#editor?.monacoEditor?.getAction('i')?.run()}> + I + +
+
+ this.#editor?.monacoEditor?.getAction('q')?.run()}> + + + this.#editor?.monacoEditor?.getAction('ol')?.run()}> + + + this.#editor?.monacoEditor?.getAction('ul')?.run()}> + + +
+
+ this.#editor?.monacoEditor?.getAction('fenced-code')?.run()}> + + + this.#editor?.monacoEditor?.getAction('line')?.run()}> + + + this.#editor?.monacoEditor?.getAction('link')?.run()}> + + + this.#editor?.monacoEditor?.getAction('image')?.run()}> + + +
+
+ { + this._focusEditor(); + this.#editor?.monacoEditor?.trigger('', 'editor.action.quickCommand', ''); + }}> + F1 + +
`; + } + + 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`
- + //TODO: Why is the theme dark in Backoffice, but light in Storybook? + return html`
${this._renderBasicActions()}
+ ${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; + } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/link-picker/link-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/link-picker/link-picker-modal.element.ts index aece02e1a1..df38f1f184 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/link-picker/link-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/link-picker/link-picker-modal.element.ts @@ -153,6 +153,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElementLink to page this._handleSelectionChange(event, 'document')} .selection=${[this._selectedKey ?? '']} @@ -163,6 +164,7 @@ export class UmbLinkPickerModalElement extends UmbModalBaseElementLink to media this._handleSelectionChange(event, 'media')} .selection=${[this._selectedKey ?? '']}