From ef243ef9335e1f283281abd75ee8823fee599a98 Mon Sep 17 00:00:00 2001 From: Julia Gru <56249914+julczka@users.noreply.github.com> Date: Wed, 22 Mar 2023 10:56:28 +0100 Subject: [PATCH] Feature/code editor (#607) * create component * import styles correctly * import component * show code editor element on the template workspace * wire input event * sync code property with editor value * move workers to a separate file * create class for code editor * add very simple insert method * focus after insert * make scroll bar bit nicer * add markdown example and fancier template * make insert work with multiple cursors and selections * now really make it works with selections and multiple cursors * map options * add hack to fix the jumpy cursor * Observe themes * add own model for range, return array of ranges from find * add backoffice-fit dark mode * add theme story * add themes folder * add new methods insertAtPosition getValueInRange select * add documentation to editor class * add docs * rename file * rename donut files * cleanup * add some more imports * test heap size fix * heap error test 2 * fix imports * node fail error test * rename --------- Co-authored-by: Mads Rasmussen --- .../.github/workflows/build_test.yml | 3 + src/Umbraco.Web.UI.Client/package-lock.json | 11 + src/Umbraco.Web.UI.Client/package.json | 1 + .../code-editor/code-editor.controller.ts | 370 ++++++++++++++++++ .../code-editor/code-editor.element.ts | 172 ++++++++ .../code-editor/code-editor.model.ts | 229 +++++++++++ .../code-editor/code-editor.stories.ts | 232 +++++++++++ .../shared/components/code-editor/index.ts | 8 + .../components/code-editor/languageWorkers.ts | 33 ++ .../shared/components/code-editor/styles.ts | 14 + .../themes/code-editor.dark.theme.ts | 10 + .../themes/code-editor.hc-dark.theme.ts | 8 + .../themes/code-editor.hc-light.theme.ts | 8 + .../themes/code-editor.light.theme.ts | 8 + .../components/code-editor/themes/index.ts | 24 ++ ...{donut-chart.ts => donut-chart.element.ts} | 4 +- .../donut-chart/donut-chart.stories.ts | 4 +- ...{donut-slice.ts => donut-slice.element.ts} | 0 .../shared/components/donut-chart/index.ts | 4 +- .../src/backoffice/shared/components/index.ts | 1 + .../workspace/template-workspace.element.ts | 50 ++- .../src/core/mocks/data/template.data.ts | 15 +- 22 files changed, 1191 insertions(+), 18 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.controller.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.model.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.stories.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/languageWorkers.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/styles.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.dark.theme.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-dark.theme.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-light.theme.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.light.theme.ts create mode 100644 src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/index.ts rename src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/{donut-chart.ts => donut-chart.element.ts} (99%) rename src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/{donut-slice.ts => donut-slice.element.ts} (100%) diff --git a/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml b/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml index 5a1a10a376..f806a8eaa1 100644 --- a/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml +++ b/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml @@ -15,6 +15,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +env: + NODE_OPTIONS: --max_old_space_size=16384 + jobs: build: diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index b12d83fb70..524f3d9036 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -18,6 +18,7 @@ "element-internals-polyfill": "^1.1.19", "lit": "^2.6.1", "lodash-es": "4.17.21", + "monaco-editor": "^0.36.1", "router-slot": "file:router-slot-1.6.1.tgz", "rxjs": "^7.8.0", "uuid": "^9.0.0" @@ -13446,6 +13447,11 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/monaco-editor": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.36.1.tgz", + "integrity": "sha512-/CaclMHKQ3A6rnzBzOADfwdSJ25BFoFT0Emxsc4zYVyav5SkK9iA6lEtIeuN/oRYbwPgviJT+t3l+sjFa28jYg==" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -28332,6 +28338,11 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "monaco-editor": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.36.1.tgz", + "integrity": "sha512-/CaclMHKQ3A6rnzBzOADfwdSJ25BFoFT0Emxsc4zYVyav5SkK9iA6lEtIeuN/oRYbwPgviJT+t3l+sjFa28jYg==" + }, "mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 555f6d2cca..833bb4bde1 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -68,6 +68,7 @@ "element-internals-polyfill": "^1.1.19", "lit": "^2.6.1", "lodash-es": "4.17.21", + "monaco-editor": "^0.36.1", "router-slot": "file:router-slot-1.6.1.tgz", "rxjs": "^7.8.0", "uuid": "^9.0.0" diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.controller.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.controller.ts new file mode 100644 index 0000000000..f79acaeace --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.controller.ts @@ -0,0 +1,370 @@ +import { UmbChangeEvent, UmbInputEvent } from '@umbraco-cms/backoffice/events'; +import * as monaco from 'monaco-editor'; +import { + CodeEditorConstructorOptions, + CodeEditorSearchOptions, + CodeEditorTheme, + UmbCodeEditorCursorPosition, + UmbCodeEditorCursorPositionChangedEvent, + UmbCodeEditorCursorSelectionChangedEvent, + UmbCodeEditorHost, + UmbCodeEditorRange, + UmbCodeEditorSelection, +} from './code-editor.model'; +import themes from './themes'; + +//TODO - consider firing change event on blur + +/** + * This is a wrapper class for the [monaco editor](https://microsoft.github.io/monaco-editor). It exposes some of the monaco editor API. It also handles the creation of the monaco editor. + * It allows access to the entire monaco editor object through `monacoEditor` property, but mind the fact that editor might be swapped in the future for a different library, so use on your own responsibility. + * Through the UmbCodeEditorHost interface it can be used in a custom element. + * By using monaco library directly you can access the entire monaco API along with code completions, actions etc. This class creates some level of abstraction over the monaco editor. It only provides basic functionality, that should be enough for most of the use cases and should be possible to implement with any code editor library. + * + * Current issues: [shadow DOM related issues](https://github.com/microsoft/monaco-editor/labels/editor-shadow-dom) #3217 currently fixed by a hack , [razor syntax highlight](https://github.com/microsoft/monaco-editor/issues/1997) + * + * + * @export + * @class UmbCodeEditor + */ +export class UmbCodeEditorController { + #host: UmbCodeEditorHost; + #editor?: monaco.editor.IStandaloneCodeEditor; + /** + * The monaco editor object. This is the actual monaco editor object. It is exposed for advanced usage, but mind the fact that editor might be swapped in the future for a different library, so use on your own responsibility. For more information see [monaco editor API](https://microsoft.github.io/monaco-editor/docs.html#interfaces/editor.IStandaloneCodeEditor.html). + * + * @readonly + * @memberof UmbCodeEditor + */ + get monacoEditor() { + return this.#editor; + } + + #options: CodeEditorConstructorOptions = {}; + /** + * The options used to create the editor. + * + * @readonly + * @type {CodeEditorConstructorOptions} + * @memberof UmbCodeEditor + */ + get options(): CodeEditorConstructorOptions { + return this.#options; + } + + #defaultMonacoOptions: monaco.editor.IStandaloneEditorConstructionOptions = { + automaticLayout: true, + scrollBeyondLastLine: false, + scrollbar: { + verticalScrollbarSize: 5, + }, + // disable this, as it does not work with shadow dom properly. + colorDecorators: false, + }; + + #position: UmbCodeEditorCursorPosition | null = null; + /** + * Provides the current position of the cursor. + * + * @readonly + * @memberof UmbCodeEditor + */ + get position() { + return this.#position; + } + #secondaryPositions: UmbCodeEditorCursorPosition[] = []; + /** + * Provides positions of all the secondary cursors. + * + * @readonly + * @memberof UmbCodeEditor + */ + get secondaryPositions() { + return this.#secondaryPositions; + } + + /** + * Provides the current value of the editor. + * + * @memberof UmbCodeEditor + */ + get value() { + if (!this.#editor) return ''; + const value = this.#editor.getValue(); + return value; + } + + set value(newValue: string) { + if (!this.#editor) throw new Error('Editor object not found'); + + const oldValue = this.value; + if (newValue !== oldValue) { + this.#editor.setValue(newValue); + } + } + /** + * Provides the current model of the editor. For advanced usage. Bare in mind that in case of the monaco library being swapped in the future, this might not be available. For more information see [monaco editor model API](https://microsoft.github.io/monaco-editor/docs.html#interfaces/editor.ITextModel.html). + * + * @readonly + * @memberof UmbCodeEditor + */ + get monacoModel() { + if (!this.#editor) return null; + return this.#editor.getModel(); + } + /** + * Creates an instance of UmbCodeEditor. You should instantiate this class through the `UmbCodeEditorHost` interface and that should happen when inside DOM nodes of the host container are available, otherwise the editor will not be able to initialize, for example in lit `firstUpdated` lifecycle hook. It will make host emit change and input events when the value of the editor changes. + * @param {UmbCodeEditorHost} host + * @param {CodeEditorConstructorOptions} [options] + * @memberof UmbCodeEditor + */ + constructor(host: UmbCodeEditorHost, options?: CodeEditorConstructorOptions) { + this.#options = { ...options }; + this.#host = host; + this.#registerThemes(); + this.#createEditor(options); + } + + #registerThemes() { + Object.entries(themes).forEach(([name, theme]) => { + this.#defineTheme(name, theme); + }); + } + + #defineTheme(name: string, theme: monaco.editor.IStandaloneThemeData) { + monaco.editor.defineTheme(name, theme); + } + + #initiateEvents() { + this.#editor?.onDidChangeModelContent(() => { + this.#host.code = this.value ?? ''; + this.#host.dispatchEvent(new UmbInputEvent()); + }); + + this.#editor?.onDidChangeModel(() => { + this.#host.dispatchEvent(new UmbChangeEvent()); + }); + this.#editor?.onDidChangeCursorPosition((e) => { + this.#position = e.position; + this.#secondaryPositions = e.secondaryPositions; + }); + } + + #mapOptions(options: CodeEditorConstructorOptions): monaco.editor.IStandaloneEditorConstructionOptions { + const hasLineNumbers = Object.prototype.hasOwnProperty.call(options, 'lineNumbers'); + const hasMinimap = Object.prototype.hasOwnProperty.call(options, 'minimap'); + const hasLightbulb = Object.prototype.hasOwnProperty.call(options, 'lightbulb'); + + return { + ...options, + lineNumbers: hasLineNumbers ? (options.lineNumbers ? 'on' : 'off') : undefined, + minimap: hasMinimap ? (options.minimap ? { enabled: true } : { enabled: false }) : undefined, + lightbulb: hasLightbulb ? (options.lightbulb ? { enabled: true } : { enabled: false }) : undefined, + }; + } + /** + * Updates the options of the editor. This is useful for updating the options after the editor has been created. + * + * @param {CodeEditorConstructorOptions} newOptions + * @memberof UmbCodeEditor + */ + updateOptions(newOptions: CodeEditorConstructorOptions) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#options = { ...this.#options, ...newOptions }; + this.#editor.updateOptions(this.#mapOptions(newOptions)); + } + + #createEditor(options: CodeEditorConstructorOptions = {}) { + if (!this.#host.container) throw new Error('Container not found'); + if (this.#host.container.hasChildNodes()) throw new Error('Editor container should be empty'); + + const mergedOptions = { ...this.#defaultMonacoOptions, ...this.#mapOptions(options) }; + + this.#editor = monaco.editor.create(this.#host.container, { + ...mergedOptions, + value: this.#host.code ?? '', + language: this.#host.language, + theme: this.#host.theme, + readOnly: this.#host.readonly, + ariaLabel: this.#host.label, + }); + this.#initiateEvents(); + } + /** + * Provides the current selections of the editor. + * + * @return {*} {UmbCodeEditorSelection[]} + * @memberof UmbCodeEditor + */ + getSelections(): UmbCodeEditorSelection[] { + if (!this.#editor) return []; + return this.#editor.getSelections() ?? []; + } + /** + * Provides the current positions of the cursor or multiple cursors. + * + * @return {*} {(UmbCodeEditorCursorPosition | null)} + * @memberof UmbCodeEditor + */ + getPositions(): UmbCodeEditorCursorPosition | null { + if (!this.#editor) return null; + return this.#editor.getPosition(); + } + /** + * Inserts text at the current cursor position or multiple cursor positions. + * + * @param {string} text + * @memberof UmbCodeEditor + */ + insert(text: string) { + if (!this.#editor) throw new Error('Editor object not found'); + const selections = this.#editor.getSelections() ?? []; + if (selections?.length > 0) { + this.#editor.executeEdits( + null, + selections.map((selection) => ({ range: selection, text })) + ); + } + } + /** + * Looks for a string or matching strings in the editor and returns the ranges of the found strings. Can use regex, case sensitive and more. If you want regex set the isRegex to true in the options. + * + * @param {string} searchString + * @param {CodeEditorSearchOptions} [searchOptions={}] + * @return {*} {UmbCodeEditorRange[]} + * @memberof UmbCodeEditor + */ + find( + searchString: string, + searchOptions: CodeEditorSearchOptions = {} + ): UmbCodeEditorRange[] { + if (!this.#editor) throw new Error('Editor object not found'); + const defaultOptions = { + searchOnlyEditableRange: false, + + isRegex: false, + + matchCase: false, + + wordSeparators: null, + + captureMatches: false, + }; + + const { searchOnlyEditableRange, isRegex, matchCase, wordSeparators, captureMatches } = { + ...defaultOptions, + ...searchOptions, + }; + return ( + this.monacoModel + ?.findMatches(searchString, searchOnlyEditableRange, isRegex, matchCase, wordSeparators, captureMatches) + .map((findMatch) => ({ + startLineNumber: findMatch.range.startLineNumber, + startColumn: findMatch.range.startColumn, + endLineNumber: findMatch.range.endLineNumber, + endColumn: findMatch.range.endColumn, + })) ?? [] + ); + } + /** + * Returns the value of the editor for a given range. + * + * @param {UmbCodeEditorRange} range + * @return {*} {string} + * @memberof UmbCodeEditor + */ + getValueInRange(range: UmbCodeEditorRange): string { + if (!this.#editor) throw new Error('Editor object not found'); + return this.monacoModel?.getValueInRange(range) ?? ''; + } + /** + * Inserts text at a given position. + * + * @param {string} text + * @param {UmbCodeEditorCursorPosition} position + * @memberof UmbCodeEditor + */ + insertAtPosition(text: string, position: UmbCodeEditorCursorPosition) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.executeEdits(null, [ + { + range: { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column, + }, + text, + }, + ]); + } + /** + * Selects a range of text in the editor. + * + * @param {UmbCodeEditorRange} range + * @memberof UmbCodeEditor + */ + select(range: UmbCodeEditorRange) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.setSelection(range); + } + /** + * Changes the theme of the editor. + * + * @template T + * @param {(CodeEditorTheme | T)} theme + * @memberof UmbCodeEditor + */ + setTheme(theme: CodeEditorTheme | T) { + if (!this.#editor) throw new Error('Editor object not found'); + monaco.editor.setTheme(theme); + } + /** + * Runs callback on change of model content. (for example when typing) + * + * @param {() => void} callback + * @memberof UmbCodeEditor + */ + onChangeModelContent(callback: () => void) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.onDidChangeModelContent(() => { + callback(); + }); + } + /** + * Runs callback on change of model (when the entire model is replaced ) + * + * @param {() => void} callback + * @memberof UmbCodeEditor + */ + onDidChangeModel(callback: () => void) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.onDidChangeModel(() => { + callback(); + }); + } + /** + * Runs callback on change of cursor position. Gives as parameter the new position. + * + * @param {((e: UmbCodeEditorCursorPositionChangedEvent | undefined) => void)} callback + * @memberof UmbCodeEditor + */ + onDidChangeCursorPosition(callback: (e: UmbCodeEditorCursorPositionChangedEvent | undefined) => void) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.onDidChangeCursorPosition((event) => { + callback(event); + }); + } + /** + * Runs callback on change of cursor selection. Gives as parameter the new selection. + * + * @param {((e: UmbCodeEditorCursorSelectionChangedEvent | undefined) => void)} callback + * @memberof UmbCodeEditor + */ + onDidChangeCursorSelection(callback: (e: UmbCodeEditorCursorSelectionChangedEvent | undefined) => void) { + if (!this.#editor) throw new Error('Editor object not found'); + this.#editor.onDidChangeCursorSelection((event) => { + callback(event); + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.element.ts new file mode 100644 index 0000000000..876d94e08f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.element.ts @@ -0,0 +1,172 @@ +import { css, html, PropertyValues } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, Ref, ref } from 'lit/directives/ref.js'; +import { UMB_THEME_CONTEXT_TOKEN } from '../../../themes/theme.context'; +import { UmbCodeEditorController } from './code-editor.controller'; +import { CodeEditorLanguage, CodeEditorTheme, UmbCodeEditorHost } from './code-editor.model'; +import { monacoEditorStyles, monacoJumpingCursorHack } from './styles'; +import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; +/** + * A custom element that renders a code editor. Code editor is based on the Monaco Editor library. + * The element will listen to the theme context and update the theme accordingly. + * Parts of the monaco Api is exposed through the `editor` property. You can access the monaco editor instance through `editor.monacoEditor`. + * + * @element umb-code-editor + * + * @export + * @class UmbCodeEditorElement + * @extends {UmbLitElement} + * @implements {UmbCodeEditorHost} + * @fires input - Fired when the value of the editor changes. + * @fires change - Fired when the entire model of editor is replaced. + */ +@customElement('umb-code-editor') +export class UmbCodeEditorElement extends UmbLitElement implements UmbCodeEditorHost { + static styles = [ + monacoEditorStyles, + monacoJumpingCursorHack, + css` + :host { + display: block; + } + #editor-container { + width: var(--editor-width); + height: var(--editor-height, 100%); + + --vscode-scrollbar-shadow: #dddddd; + --vscode-scrollbarSlider-background: var(--uui-color-disabled-contrast); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); + } + `, + ]; + + private containerRef: Ref = createRef(); + + get container() { + if (!this.containerRef?.value) throw new Error('Container not found'); + return this.containerRef!.value; + } + + #editor?: UmbCodeEditorController; + + get editor() { + return this.#editor; + } + /** + * Theme of the editor. Default is light. Element will listen to the theme context and update the theme accordingly. + * + * @type {CodeEditorTheme} + * @memberof UmbCodeEditorElement + */ + @property() + theme: CodeEditorTheme = CodeEditorTheme.Light; + /** + * Language of the editor. Default is javascript. + * + * @type {CodeEditorLanguage} + * @memberof UmbCodeEditorElement + */ + @property() + language: CodeEditorLanguage = 'javascript'; + /** + * Label of the editor. Default is 'Code Editor'. + * + * @memberof UmbCodeEditorElement + */ + @property() + label = 'Code Editor'; + + //TODO - this should be called a value + #code = ''; + /** + * Value of the editor. Default is empty string. + * + * @readonly + * @memberof UmbCodeEditorElement + */ + @property() + get code() { + return this.#code; + } + + set code(value: string) { + const oldValue = this.#code; + this.#code = value; + if (this.#editor) { + this.#editor.value = value; + } + this.requestUpdate('code', oldValue); + } + /** + * Whether the editor is readonly. Default is false. + * + * @memberof UmbCodeEditorElement + */ + @property({ type: Boolean, attribute: 'readonly' }) + readonly = false; + + constructor() { + super(); + this.consumeContext(UMB_THEME_CONTEXT_TOKEN, (instance) => { + instance.theme.subscribe((themeAlias) => { + this.theme = themeAlias ? this.#translateTheme(themeAlias) : CodeEditorTheme.Light; + }); + }); + } + + firstUpdated() { + this.#editor = new UmbCodeEditorController(this); + } + + protected updated(_changedProperties: PropertyValues): void { + if (_changedProperties.has('theme') || _changedProperties.has('language')) { + this.#editor?.updateOptions({ + theme: this.theme, + language: this.language, + }); + } + } + + #translateTheme(theme: string) { + switch (theme) { + case 'umb-light-theme': + return CodeEditorTheme.Light; + case 'umb-dark-theme': + return CodeEditorTheme.Dark; + case 'umb-high-contrast-theme': + return CodeEditorTheme.HighContrastLight; + default: + return CodeEditorTheme.Light; + } + } + /** + * Inserts text at the current cursor position. + * + * @param {string} text + * @memberof UmbCodeEditorElement + */ + insert(text: string) { + this.#editor?.insert(text); + } + /** + * Finds all occurrence of the given string or matches the given regular expression. + * + * @param {string} text + * @return {*} + * @memberof UmbCodeEditorElement + */ + find(text: string) { + return this.#editor?.find(text); + } + + render() { + return html`
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-code-editor': UmbCodeEditorElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.model.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.model.ts new file mode 100644 index 0000000000..e093a81084 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.model.ts @@ -0,0 +1,229 @@ +export type CodeEditorLanguage = 'razor' | 'typescript' | 'javascript' | 'css' | 'markdown' | 'json' | 'html'; + +export enum CodeEditorTheme { + Light = 'umb-light', + Dark = 'umb-dark', + HighContrastLight = 'umb-hc-light', + HighContrastDark = 'umb-hc-dark', +} + +export interface UmbCodeEditorHost extends HTMLElement { + container: HTMLElement; + language: CodeEditorLanguage; + theme: CodeEditorTheme; + code: string; + readonly: boolean; + label: string; +} + +export interface UmbCodeEditorCursorPosition { + column: number; + lineNumber: number; +} + +export interface UmbCodeEditorRange { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; +} + +export interface UmbCodeEditorSelection { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + positionColumn: number; + positionLineNumber: number; + selectionStartColumn: number; + selectionStartLineNumber: number; +} + +export interface UmbCodeEditorCursorPositionChangedEvent { + position: UmbCodeEditorCursorPosition; + secondaryPositions: UmbCodeEditorCursorPosition[]; +} + +export interface UmbCodeEditorCursorSelectionChangedEvent { + selection: UmbCodeEditorSelection; + secondarySelections: UmbCodeEditorSelection[]; +} + +export interface CodeEditorConstructorOptions { + /** + * The initial value of the auto created model in the editor. + */ + value?: string; + /** + * The initial language of the auto created model in the editor. + */ + language?: CodeEditorLanguage; + /** + * Initial theme to be used for rendering. + * The current out-of-the-box available themes are: 'vs' (default), 'vs-dark', 'hc-black', 'hc-light. + * You can create custom themes via `monaco.editor.defineTheme`. + * To switch a theme, use `monaco.editor.setTheme`. + * **NOTE**: The theme might be overwritten if the OS is in high contrast mode, unless `autoDetectHighContrast` is set to false. + */ + theme?: CodeEditorTheme; + /** + * Container element to use for ARIA messages. + * Defaults to document.body. + */ + ariaContainerElement?: HTMLElement; + /** + * The aria label for the editor's textarea (when it is focused). + */ + ariaLabel?: string; + /** + * The `tabindex` property of the editor's textarea + */ + tabIndex?: number; + + /** + * Control the rendering of line numbers. + * Defaults to `true`. + */ + lineNumbers?: boolean; + /** + * Class name to be added to the editor. + */ + extraEditorClassName?: string; + /** + * Should the editor be read only. See also `domReadOnly`. + * Defaults to false. + */ + readOnly?: boolean; + /** + * Control the behavior and rendering of the minimap. + */ + minimap?: boolean; + /** + * Enable that the editor will install a ResizeObserver to check if its container dom node size has changed. + * Defaults to false. + */ + automaticLayout?: boolean; + /** + * Control the wrapping of the editor. + * When `wordWrap` = "off", the lines will never wrap. + * When `wordWrap` = "on", the lines will wrap at the viewport width. + * When `wordWrap` = "wordWrapColumn", the lines will wrap at `wordWrapColumn`. + * When `wordWrap` = "bounded", the lines will wrap at min(viewport width, wordWrapColumn). + * Defaults to "off". + */ + wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded'; + /** + * Enable detecting links and making them clickable. + * Defaults to true. + */ + links?: boolean; + /** + * Enable inline color decorators and color picker rendering. + */ + colorDecorators?: boolean; + /** + * Controls the max number of color decorators that can be rendered in an editor at once. + */ + colorDecoratorsLimit?: number; + /** + * Enable custom contextmenu. + * Defaults to true. + */ + contextmenu?: boolean; + /** + * The modifier to be used to add multiple cursors with the mouse. + * Defaults to 'alt' + */ + multiCursorModifier?: 'ctrlCmd' | 'alt'; + /** + * Controls the max number of text cursors that can be in an active editor at once. + */ + multiCursorLimit?: number; + /** + * Controls the number of lines in the editor that can be read out by a screen reader + */ + accessibilityPageSize?: number; + /** + * Controls the spacing around the editor. + * @type {{bottom: number; top: number}} + * @memberof CodeEditorConstructorOptions + */ + padding?: { bottom: number; top: number }; + /** + * Controls if the editor should allow to move selections via drag and drop. + * Defaults to false. + */ + dragAndDrop?: boolean; + /** + * Show code lens + * Defaults to true. + */ + codeLens?: boolean; + /** + * Control the behavior and rendering of the code action lightbulb. + */ + lightbulb?: boolean; + /** + * Enable code folding. + * Defaults to true. + */ + folding?: boolean; + /** + * The font family + */ + fontFamily?: string; + /** + * The font weight + */ + fontWeight?: string; + /** + * The font size + */ + fontSize?: number; + /** + * The line height + */ + lineHeight?: number; + /** + * The letter spacing + */ + letterSpacing?: number; +} + +export interface CodeEditorSearchOptions { + /** + * Limit the searching to only search inside the editable range of the model. + * + * @type {boolean} + * @memberof CodeEditorSearchOptions + */ + searchOnlyEditableRange: boolean; + /** + * Used to indicate that searchString is a regular expression. + * + * @type {boolean} + * @memberof CodeEditorSearchOptions + */ + isRegex: boolean; + /** + * Force the matching to match lower/upper case exactly. + * + * @type {boolean} + * @memberof CodeEditorSearchOptions + */ + matchCase: boolean; + /** + * Force the matching to match entire words only. Pass null otherwise. + * + * @type {string} + * @memberof CodeEditorSearchOptions + */ + wordSeparators: string | null; + /** + * The result will contain the captured groups. + * + * @type {boolean} + * @memberof CodeEditorSearchOptions + */ + captureMatches: boolean; +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.stories.ts new file mode 100644 index 0000000000..bfa042e01b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/code-editor.stories.ts @@ -0,0 +1,232 @@ +import { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; +import { UmbCodeEditorElement } from './code-editor.element'; +import { CodeEditorLanguage, CodeEditorTheme } from './code-editor.model'; + +const meta: Meta = { + title: 'Components/Code Editor', + component: 'umb-code-editor', + decorators: [(story) => html`
${story()}
`], + parameters: { layout: 'fullscreen' }, + argTypes: { + theme: { + control: 'select', + options: [ + CodeEditorTheme.Dark, + CodeEditorTheme.Light, + CodeEditorTheme.HighContrastLight, + CodeEditorTheme.HighContrastLight, + ], + }, + }, +}; + +const codeSnippets: Record = { + javascript: `// Returns "banana" + ('b' + 'a' + + 'a' + 'a').toLowerCase();`, + css: `:host { + display: flex; + background-color: var(--uui-color-background); + width: 100%; + height: 100%; + flex-direction: column; + } + + #header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 70px; + background-color: var(--uui-color-surface); + border-bottom: 1px solid var(--uui-color-border); + box-sizing: border-box; + } + + #headline { + display: block; + margin: 0 var(--uui-size-layout-1); + } + + #tabs { + margin-left: auto; + }`, + html: ` + + + Page Title + + + +

This is a Heading

+

This is a paragraph.

+ + + `, + razor: `@using Umbraco.Extensions + @inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + @{ + if (Model?.Areas.Any() != true) { return; } + } + +
+ @foreach (var area in Model.Areas) + { + @await Html.GetBlockGridItemAreaHtmlAsync(area) + } +
`, + markdown: ` + You will like those projects! + + --- + + # h1 Heading 8-) + ## h2 Heading + ### h3 Heading + #### h4 Heading + ##### h5 Heading + ###### h6 Heading + + + ## Horizontal Rules + + ___ + + --- + + *** + + + ## Typographic replacements + + Enable typographer option to see result. + + (c) (C) (r) (R) (tm) (TM) (p) (P) +- + + test.. test... test..... test?..... test!.... + + !!!!!! ???? ,, -- --- + + "Smartypants, double quotes" and 'single quotes'`, + typescript: `import { UmbTemplateRepository } from '../repository/template.repository'; + import { UmbWorkspaceContext } from '../../../shared/components/workspace/workspace-context/workspace-context'; + import { createObservablePart, DeepState } from '@umbraco-cms/observable-api'; + import { TemplateModel } from '@umbraco-cms/backend-api'; + import { UmbControllerHostInterface } from '@umbraco-cms/controller'; + + export class UmbTemplateWorkspaceContext extends UmbWorkspaceContext { + #data = new DeepState(undefined); + data = this.#data.asObservable(); + name = createObservablePart(this.#data, (data) => data?.name); + content = createObservablePart(this.#data, (data) => data?.content); + + constructor(host: UmbControllerHostInterface) { + super(host, new UmbTemplateRepository(host)); + } + + getData() { + return this.#data.getValue(); + } + + setName(value: string) { + this.#data.next({ ...this.#data.value, $type: this.#data.value?.$type || '', name: value }); + } + + setContent(value: string) { + this.#data.next({ ...this.#data.value, $type: this.#data.value?.$type || '', content: value }); + } + + async load(entityKey: string) { + const { data } = await this.repository.requestByKey(entityKey); + if (data) { + this.setIsNew(false); + this.#data.next(data); + } + } + + async createScaffold(parentKey: string | null) { + const { data } = await this.repository.createScaffold(parentKey); + if (!data) return; + this.setIsNew(true); + this.#data.next(data); + } + }`, + json: `{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "lib": ["es2020", "dom", "dom.iterable"], + "declaration": true, + "emitDeclarationOnly": true, + "noEmitOnError": true, + "outDir": "./types", + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": false, + "skipLibCheck": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@umbraco-cms/css": ["libs/css/custom-properties.css"], + "@umbraco-cms/modal": ["src/core/modal"], + "@umbraco-cms/models": ["libs/models"], + "@umbraco-cms/backend-api": ["libs/backend-api"], + "@umbraco-cms/context-api": ["libs/context-api"], + "@umbraco-cms/controller": ["libs/controller"], + "@umbraco-cms/element": ["libs/element"], + "@umbraco-cms/extensions-api": ["libs/extensions-api"], + "@umbraco-cms/extensions-registry": ["libs/extensions-registry"], + "@umbraco-cms/notification": ["libs/notification"], + "@umbraco-cms/observable-api": ["libs/observable-api"], + "@umbraco-cms/events": ["libs/events"], + "@umbraco-cms/entity-action": ["libs/entity-action"], + "@umbraco-cms/workspace": ["libs/workspace"], + "@umbraco-cms/utils": ["libs/utils"], + "@umbraco-cms/router": ["libs/router"], + "@umbraco-cms/test-utils": ["libs/test-utils"], + "@umbraco-cms/repository": ["libs/repository"], + "@umbraco-cms/resources": ["libs/resources"], + "@umbraco-cms/store": ["libs/store"], + "@umbraco-cms/components/*": ["src/backoffice/components/*"], + "@umbraco-cms/sections/*": ["src/backoffice/sections/*"] + } + }, + "include": ["src/**/*.ts", "apps/**/*.ts", "libs/**/*.ts", "e2e/**/*.ts"], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] + }`, +}; + +export default meta; +type Story = StoryObj; + +const [Javascript, Css, Html, Razor, Markdown, Typescript, Json]: Story[] = Object.keys(codeSnippets).map( + (language) => { + return { + args: { + language: language as CodeEditorLanguage, + code: codeSnippets[language as CodeEditorLanguage], + }, + }; + } +); + +const Themes: Story = { + args: { + language: 'javascript', + code: codeSnippets.javascript, + theme: CodeEditorTheme.Dark, + }, +}; + +export { Javascript, Css, Html, Razor, Markdown, Typescript, Json, Themes }; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/index.ts new file mode 100644 index 0000000000..36b9c2e5c2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/index.ts @@ -0,0 +1,8 @@ +import * as initializeWorkers from './languageWorkers'; +import { UmbCodeEditorElement } from './code-editor.element'; +import { UmbCodeEditorController } from './code-editor.controller'; +import { monacoEditorStyles } from './styles'; + +export default UmbCodeEditorElement; + +export { initializeWorkers, UmbCodeEditorController as UmbCodeEditor, UmbCodeEditorElement, monacoEditorStyles }; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/languageWorkers.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/languageWorkers.ts new file mode 100644 index 0000000000..8fd766ec2e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/languageWorkers.ts @@ -0,0 +1,33 @@ +//eslint-disable-next-line +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +//eslint-disable-next-line +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +//eslint-disable-next-line +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +//eslint-disable-next-line +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +//eslint-disable-next-line +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +export const initializeWorkers = () => { + self.MonacoEnvironment = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getWorker(_: any, label: string) { + if (label === 'json') { + return new jsonWorker(); + } + if (label === 'css' || label === 'scss' || label === 'less') { + return new cssWorker(); + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return new htmlWorker(); + } + if (label === 'typescript' || label === 'javascript') { + return new tsWorker(); + } + return new editorWorker(); + }, + }; +}; + +initializeWorkers(); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/styles.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/styles.ts new file mode 100644 index 0000000000..00f7a55e4d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/styles.ts @@ -0,0 +1,14 @@ +import { css, unsafeCSS } from 'lit'; +import styles from 'monaco-editor/min/vs/editor/editor.main.css?inline'; + +export const monacoEditorStyles = css` + ${unsafeCSS(styles)} +`; + +export const monacoJumpingCursorHack = css` + /* a hacky workaround this issue: https://github.com/microsoft/monaco-editor/issues/3217 + should probably be removed when the issue is fixed */ + .view-lines { + font-feature-settings: revert !important; + } +`; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.dark.theme.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.dark.theme.ts new file mode 100644 index 0000000000..cb746a8db7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.dark.theme.ts @@ -0,0 +1,10 @@ +import * as monaco from 'monaco-editor'; + +export const UmbCodeEditorThemeDark: monaco.editor.IStandaloneThemeData = { + base: 'vs-dark', + inherit: true, // can also be false to completely replace the builtin rules + rules: [], + colors: { + 'editor.background': '#21262e', + }, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-dark.theme.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-dark.theme.ts new file mode 100644 index 0000000000..6e5083021f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-dark.theme.ts @@ -0,0 +1,8 @@ +import * as monaco from 'monaco-editor'; + +export const UmbCodeEditorThemeHighContrastDark: monaco.editor.IStandaloneThemeData = { + base: 'vs-dark', + inherit: true, // can also be false to completely replace the builtin rules + rules: [], + colors: {}, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-light.theme.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-light.theme.ts new file mode 100644 index 0000000000..f783ff22ab --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.hc-light.theme.ts @@ -0,0 +1,8 @@ +import * as monaco from 'monaco-editor'; + +export const UmbCodeEditorThemeHighContrastLight: monaco.editor.IStandaloneThemeData = { + base: 'vs', + inherit: true, // can also be false to completely replace the builtin rules + rules: [], + colors: {}, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.light.theme.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.light.theme.ts new file mode 100644 index 0000000000..1c5c98cbb3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/code-editor.light.theme.ts @@ -0,0 +1,8 @@ +import * as monaco from 'monaco-editor'; + +export const UmbCodeEditorThemeLight: monaco.editor.IStandaloneThemeData = { + base: 'vs', + inherit: true, // can also be false to completely replace the builtin rules + rules: [], + colors: {}, +}; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/index.ts new file mode 100644 index 0000000000..89c7798708 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/code-editor/themes/index.ts @@ -0,0 +1,24 @@ +import * as monaco from 'monaco-editor'; +import { CodeEditorTheme } from '../code-editor.model'; +import { UmbCodeEditorThemeHighContrastLight } from './code-editor.hc-light.theme'; +import { UmbCodeEditorThemeHighContrastDark } from './code-editor.hc-dark.theme'; +import { UmbCodeEditorThemeLight } from './code-editor.light.theme'; +import { UmbCodeEditorThemeDark } from './code-editor.dark.theme'; +/** + * 4 themes for the code editor. + * + * @type {*} */ +const themes: Record = { + 'umb-dark': UmbCodeEditorThemeDark, + 'umb-light': UmbCodeEditorThemeLight, + 'umb-hc-light': UmbCodeEditorThemeHighContrastLight, + 'umb-hc-dark': UmbCodeEditorThemeHighContrastDark, +}; +export { + UmbCodeEditorThemeDark, + UmbCodeEditorThemeLight, + UmbCodeEditorThemeHighContrastLight, + UmbCodeEditorThemeHighContrastDark, + themes, +}; +export default themes; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.element.ts similarity index 99% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.element.ts index 916ce79310..fb3a7bfcd4 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.element.ts @@ -2,7 +2,7 @@ import { UUITextStyles } from '@umbraco-ui/uui-css'; import { css, html, LitElement, svg } from 'lit'; import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js'; import { clamp } from 'lodash-es'; -import { UmbDonutSliceElement } from './donut-slice'; +import { UmbDonutSliceElement } from './donut-slice.element'; export interface Circle { color: string; @@ -180,8 +180,6 @@ export class UmbDonutChartElement extends LitElement { this.#printCircles(); } - - } #calculatePercentage(partialValue: number) { diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.stories.ts index efeb9c3405..94a935efb6 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.stories.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-chart.stories.ts @@ -1,5 +1,5 @@ -import './donut-slice'; -import './donut-chart'; +import './donut-slice.element'; +import './donut-chart.element'; import { Meta } from '@storybook/web-components'; import { html } from 'lit'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-slice.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-slice.element.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-slice.ts rename to src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/donut-slice.element.ts diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/index.ts index 218f52b18f..b01b77e83f 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/donut-chart/index.ts @@ -1,2 +1,2 @@ -export * from './donut-chart'; -export * from './donut-slice'; +export * from './donut-chart.element'; +export * from './donut-slice.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts index 2078c9d6ad..6bdba312a4 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/components/index.ts @@ -45,6 +45,7 @@ import './history/history-item.element'; import './workspace/workspace-action/workspace-action.element'; import './workspace/workspace-layout/workspace-layout.element'; +import './code-editor'; import './workspace/workspace-footer-layout/workspace-footer-layout.element'; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/templating/templates/workspace/template-workspace.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/templating/templates/workspace/template-workspace.element.ts index 01ed7e744f..461f748a51 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/templating/templates/workspace/template-workspace.element.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/templating/templates/workspace/template-workspace.element.ts @@ -1,7 +1,8 @@ import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; import { css, html } from 'lit'; -import { customElement, state } from 'lit/decorators.js'; -import { UUIInputElement, UUITextareaElement } from '@umbraco-ui/uui'; +import { customElement, query, state } from 'lit/decorators.js'; +import { UUIInputElement } from '@umbraco-ui/uui'; +import { UmbCodeEditorElement } from '../../../shared/components/code-editor/code-editor.element'; import { UmbTemplateWorkspaceContext } from './template-workspace.context'; import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; @@ -16,8 +17,18 @@ export class UmbTemplateWorkspaceElement extends UmbLitElement { height: 100%; } - #content { - height: 200px; + umb-code-editor { + --editor-height: calc(100vh - 300px); + } + + uui-box { + margin: 1em; + --uui-box-default-padding: 0; + } + + uui-input { + width: 100%; + margin: 1em; } `, ]; @@ -37,6 +48,9 @@ export class UmbTemplateWorkspaceElement extends UmbLitElement { @state() private _content?: string | null = ''; + @query('umb-code-editor') + private _codeEditor?: UmbCodeEditorElement; + #templateWorkspaceContext = new UmbTemplateWorkspaceContext(this); #isNew = false; @@ -59,17 +73,35 @@ export class UmbTemplateWorkspaceElement extends UmbLitElement { this.#templateWorkspaceContext.setName(value); } - #onTextareaInput(event: Event) { - const target = event.target as UUITextareaElement; - const value = target.value as string; + //TODO - debounce that + #onCodeEditorInput(event: Event) { + const target = event.target as UmbCodeEditorElement; + const value = target.code as string; this.#templateWorkspaceContext.setContent(value); } + #insertCode(event: Event) { + const target = event.target as UUIInputElement; + const value = target.value as string; + + this._codeEditor?.insert(`My hovercraft is full of eels`); + } + render() { // TODO: add correct UI elements return html` - - + + + Insert "My hovercraft is full of eels" + + + `; } } diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/data/template.data.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/data/template.data.ts index d1714e2ac4..210f4b48d5 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/data/template.data.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/data/template.data.ts @@ -32,8 +32,19 @@ export const data: Array = [ icon: 'icon-layout', hasChildren: false, alias: 'Doc1', - content: - '@using Umbraco.Cms.Web.Common.PublishedModels;\n@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage\r\n@using ContentModels = Umbraco.Cms.Web.Common.PublishedModels;\r\n@{\r\n\tLayout = null;\r\n}', + content: `@using Umbraco.Extensions + @inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + @{ + if (Model?.Areas.Any() != true) { return; } + } + +
+ @foreach (var area in Model.Areas) + { + @await Html.GetBlockGridItemAreaHtmlAsync(area) + } +
`, }, { $type: '',