diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/rte-embed.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/rte-embed.handlers.ts index 16719a60b6..671bb2af10 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/rte-embed.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/rte-embed.handlers.ts @@ -1,22 +1,17 @@ const { rest } = window.MockServiceWorker; -import type { OEmbedResult} from '@umbraco-cms/backoffice/modal'; -import { OEmbedStatus } from '@umbraco-cms/backoffice/modal'; +import type { OEmbedResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { umbracoPath } from '@umbraco-cms/backoffice/utils'; export const handlers = [ - rest.get(umbracoPath('/rteembed'), (req, res, ctx) => { - const widthParam = req.url.searchParams.get('width'); + rest.get(umbracoPath('/oembed/query'), (req, res, ctx) => { + const widthParam = req.url.searchParams.get('maxWidth'); const width = widthParam ? parseInt(widthParam) : 360; - const heightParam = req.url.searchParams.get('height'); + const heightParam = req.url.searchParams.get('maxHeight'); const height = heightParam ? parseInt(heightParam) : 240; - const response: OEmbedResult = { - supportsDimensions: true, + const response: OEmbedResponseModel = { markup: ``, - oEmbedStatus: OEmbedStatus.Success, - width, - height, }; return res(ctx.status(200), ctx.json(response)); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor.element.ts index bd97af52bb..f42f8a68f8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor.element.ts @@ -31,7 +31,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements identifier: 'content-type-tabs-sorter', itemSelector: 'uui-tab', containerSelector: 'uui-tab-group', - disabledItemSelector: '#root-tab', + disabledItemSelector: ':not([sortable])', resolvePlacement: (args) => args.relatedRect.left + args.relatedRect.width * 0.5 > args.pointerX, onChange: ({ model }) => { this._tabs = model; @@ -47,30 +47,30 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements // Doesn't exist in model if (newIndex === -1) return; - // First in list - if (newIndex === 0 && model.length > 1) { - this.#tabsStructureHelper.partialUpdateContainer(item.id, { sortOrder: model[1].sortOrder - 1 }); - return; + // As origin we set prev sort order to -1, so if no other then our item will become 0 + let prevSortOrder = -1; + + // If not first in list, then get the sortOrder of the item before. [NL] + if (newIndex > 0 && model.length > 0) { + prevSortOrder = model[newIndex - 1].sortOrder; } - // Not first in list - if (newIndex > 0 && model.length > 1) { - const prevItemSortOrder = model[newIndex - 1].sortOrder; + // increase the prevSortOrder and use it for the moved item, + this.#tabsStructureHelper.partialUpdateContainer(item.id, { + sortOrder: ++prevSortOrder, + }); - let weight = 1; - this.#tabsStructureHelper.partialUpdateContainer(item.id, { sortOrder: prevItemSortOrder + weight }); - - // Check for overlaps - // TODO: Make sure this take inheritance into considerations. - model.some((entry, index) => { - if (index <= newIndex) return; - if (entry.sortOrder === prevItemSortOrder + weight) { - weight++; - this.#tabsStructureHelper.partialUpdateContainer(entry.id, { sortOrder: prevItemSortOrder + weight }); - } - // Break the loop - return true; + // Adjust everyone right after, until there is a gap between the sortOrders: [NL] + let i = newIndex + 1; + let entry: UmbPropertyTypeContainerModel | undefined; + // As long as there is an item with the index & the sortOrder is less or equal to the prevSortOrder, we will update the sortOrder: + while ((entry = model[i]) !== undefined && entry.sortOrder <= prevSortOrder) { + // Increase the prevSortOrder and use it for the item: + this.#tabsStructureHelper.partialUpdateContainer(entry.id, { + sortOrder: ++prevSortOrder, }); + + i++; } }, }); @@ -399,7 +399,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements ? this.localize.term('general_reorderDone') : this.localize.term('general_reorder'); - return html`
+ return html`
${this._compositionRepositoryAlias ? html` + data-umb-tab-id=${ifDefined(tab.id)} + ?sortable=${ownedTab}> ${this.renderTabInner(tab, tabActive, ownedTab)} `; } @@ -581,6 +582,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements position: relative; border-left: 1px hidden transparent; border-right: 1px solid var(--uui-color-border); + background-color: var(--uui-color-surface); } .not-active uui-button { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/embedded-media-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/embedded-media-modal.element.ts index a0177a7f7a..43f43d1823 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/embedded-media-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/embedded-media-modal.element.ts @@ -1,235 +1,146 @@ -import { css, html, unsafeHTML, when, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbOEmbedRepository } from './repository/oembed.repository.js'; +import { css, html, unsafeHTML, when, customElement, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { - OEmbedResult, - UmbEmbeddedMediaModalData, - UmbEmbeddedMediaModalValue} from '@umbraco-cms/backoffice/modal'; -import { - OEmbedStatus, - UmbModalBaseElement, -} from '@umbraco-cms/backoffice/modal'; -import { umbracoPath } from '@umbraco-cms/backoffice/utils'; - -interface UmbEmbeddedMediaModalModel { - url?: string; - info?: string; - a11yInfo?: string; - originalWidth: number; - originalHeight: number; - width: number; - height: number; - constrain: boolean; -} +import type { UmbEmbeddedMediaModalData, UmbEmbeddedMediaModalValue } from '@umbraco-cms/backoffice/modal'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import type { UUIButtonState, UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-embedded-media-modal') export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement< UmbEmbeddedMediaModalData, UmbEmbeddedMediaModalValue > { - #loading = false; - #embedResult!: OEmbedResult; - - #handleConfirm() { - this.value = { - preview: this.#embedResult.markup, - originalWidth: this._model.width, - originalHeight: this._model.originalHeight, - width: this.#embedResult.width, - height: this.#embedResult.height, - }; - this.modalContext?.submit(); - } - - #handleCancel() { - this.modalContext?.reject(); - } + #oEmbedRepository = new UmbOEmbedRepository(this); + #validUrl?: string; @state() - private _model: UmbEmbeddedMediaModalModel = { - url: '', - width: 360, - height: 240, - constrain: true, - info: '', - a11yInfo: '', - originalHeight: 240, - originalWidth: 360, - }; + private _loading?: UUIButtonState; + + @state() + private _width = 360; + + @state() + private _height = 240; + + @state() + private _url = ''; connectedCallback() { super.connectedCallback(); + if (this.data?.width) this._width = this.data.width; + if (this.data?.height) this._height = this.data.height; + if (this.data?.constrain) this.value = { ...this.value, constrain: this.data.constrain }; + if (this.data?.url) { - Object.assign(this._model, this.data); + this._url = this.data.url; this.#getPreview(); } } async #getPreview() { - this._model.info = ''; - this._model.a11yInfo = ''; + this._loading = 'waiting'; - this.#loading = true; - this.requestUpdate('_model'); + const { data } = await this.#oEmbedRepository.requestOEmbed({ + url: this._url, + maxWidth: this._width, + maxHeight: this._height, + }); - try { - // TODO => use backend cli when available - const result = await fetch( - umbracoPath('/rteembed?') + - new URLSearchParams({ - url: this._model.url, - width: this._model.width?.toString(), - height: this._model.height?.toString(), - } as { [key: string]: string }), - ); - - this.#embedResult = await result.json(); - - switch (this.#embedResult.oEmbedStatus) { - case 0: - this.#onPreviewFailed('Not supported'); - break; - case 1: - this.#onPreviewFailed('Could not embed media - please ensure the URL is valid'); - break; - case 2: - this._model.info = ''; - this._model.a11yInfo = 'Retrieved URL'; - break; - } - } catch (e) { - this.#onPreviewFailed('Could not embed media - please ensure the URL is valid'); + if (data) { + this.#validUrl = this._url; + this.value = { ...this.value, markup: data.markup, url: this.#validUrl }; + this._loading = 'success'; + } else { + this.#validUrl = undefined; + this._loading = 'failed'; } - - this.#loading = false; - this.requestUpdate('_model'); } - #onPreviewFailed(message: string) { - this._model.info = message; - this._model.a11yInfo = message; + #onUrlChange(e: UUIInputEvent) { + this._url = e.target.value as string; } - #onUrlChange(e: InputEvent) { - this._model.url = (e.target as HTMLInputElement).value; - this.requestUpdate('_model'); + #onWidthChange(e: UUIInputEvent) { + this._width = parseInt(e.target.value as string, 10); + this.#getPreview(); } - #onWidthChange(e: InputEvent) { - this._model.width = parseInt((e.target as HTMLInputElement).value, 10); - this.#changeSize('width'); - } - - #onHeightChange(e: InputEvent) { - this._model.height = parseInt((e.target as HTMLInputElement).value, 10); - this.#changeSize('height'); - } - - /** - * Calculates the width or height axis dimension when the other is changed. - * If constrain is false, axis change independently - * @param axis {string} - */ - #changeSize(axis: 'width' | 'height') { - const resize = this._model.originalWidth !== this._model.width || this._model.originalHeight !== this._model.height; - - if (this._model.constrain) { - if (axis === 'width') { - this._model.height = Math.round((this._model.width / this._model.originalWidth) * this._model.height); - } else { - this._model.width = Math.round((this._model.height / this._model.originalHeight) * this._model.width); - } - } - - this._model.originalWidth = this._model.width; - this._model.originalHeight = this._model.height; - - if (this._model.url !== '' && resize) { - this.#getPreview(); - } + #onHeightChange(e: UUIInputEvent) { + this._height = parseInt(e.target.value as string, 10); + this.#getPreview(); } #onConstrainChange() { - this._model.constrain = !this._model.constrain; - } - - /** - * If the embed does not support dimensions, or was not requested successfully - * the width, height and constrain controls are disabled - * @returns {boolean} - */ - #dimensionControlsDisabled() { - return !this.#embedResult?.supportsDimensions || this.#embedResult?.oEmbedStatus !== OEmbedStatus.Success; + const constrain = !this.value?.constrain; + this.value = { ...this.value, constrain }; } render() { return html` - +
- + + label=${this.localize.term('general_retrieve')}>
${when( - this.#embedResult?.oEmbedStatus === OEmbedStatus.Success || this._model.a11yInfo, + this.#validUrl !== undefined, () => - html` + html`
- ${when(this.#loading, () => html``)} - ${when(this.#embedResult?.markup, () => html`${unsafeHTML(this.#embedResult.markup)}`)} - ${when(this._model.info, () => html` `)} - ${when( - this._model.a11yInfo, - () => html` `, - )} + ${when(this._loading === 'waiting', () => html``)} + ${when(this.value?.markup, () => html`${unsafeHTML(this.value.markup)}`)}
`, )} - + + @change=${this.#onWidthChange} + ?disabled=${this.#validUrl ? false : true}> - + + @change=${this.#onHeightChange} + ?disabled=${this.#validUrl ? false : true}> - + + .checked=${this.value?.constrain ?? false}>
- Cancel + this.modalContext?.reject()}> + label=${this.localize.term('buttons_confirmActionConfirm')} + @click=${() => this.modalContext?.submit()}>
`; } @@ -237,27 +148,11 @@ export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement< static styles = [ UmbTextStyles, css` - h3 { - margin-left: var(--uui-size-space-5); - margin-right: var(--uui-size-space-5); - } - uui-input { width: 100%; --uui-button-border-radius: 0; } - .sr-only { - clip: rect(0, 0, 0, 0); - border: 0; - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; - } - umb-property-layout:first-child { padding-top: 0; } @@ -265,10 +160,6 @@ export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement< umb-property-layout:last-child { padding-bottom: 0; } - - p { - margin-bottom: 0; - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/index.ts new file mode 100644 index 0000000000..3d76f338dd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/index.ts @@ -0,0 +1 @@ +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/manifests.ts new file mode 100644 index 0000000000..9cf9968724 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/manifests.ts @@ -0,0 +1,13 @@ +import { manifests as repositories } from './repository/manifests.js'; +import type { ManifestModal } from '@umbraco-cms/backoffice/extension-registry'; + +const modals: Array = [ + { + type: 'modal', + alias: 'Umb.Modal.EmbeddedMedia', + name: 'Embedded Media Modal', + element: () => import('./embedded-media-modal.element.js'), + }, +]; + +export const manifests = [...modals, ...repositories]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/index.ts new file mode 100644 index 0000000000..1a6e303708 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/index.ts @@ -0,0 +1,2 @@ +export { UmbOEmbedRepository } from './oembed.repository.js'; +export { UMB_OEMBED_REPOSITORY_ALIAS } from './manifests.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/manifests.ts new file mode 100644 index 0000000000..580f3e1026 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/manifests.ts @@ -0,0 +1,13 @@ +import { UmbOEmbedRepository } from './oembed.repository.js'; +import type { ManifestRepository, ManifestTypes } from '@umbraco-cms/backoffice/extension-registry'; + +export const UMB_OEMBED_REPOSITORY_ALIAS = 'Umb.Repository.OEmbed'; + +const repository: ManifestRepository = { + type: 'repository', + alias: UMB_OEMBED_REPOSITORY_ALIAS, + name: 'OEmbed Repository', + api: UmbOEmbedRepository, +}; + +export const manifests: Array = [repository]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.repository.ts new file mode 100644 index 0000000000..344c067453 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.repository.ts @@ -0,0 +1,20 @@ +import { UmbOEmbedServerDataSource } from './oembed.server.data.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; + +export class UmbOEmbedRepository extends UmbControllerBase implements UmbApi { + #dataSource = new UmbOEmbedServerDataSource(this); + + constructor(host: UmbControllerHost) { + super(host); + } + + async requestOEmbed({ url, maxWidth, maxHeight }: { url?: string; maxWidth?: number; maxHeight?: number }) { + const { data, error } = await this.#dataSource.getOEmbedQuery({ url, maxWidth, maxHeight }); + if (!error) { + return { data }; + } + return { error }; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.server.data.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.server.data.ts new file mode 100644 index 0000000000..88c45e3eb4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/embedded-media/repository/oembed.server.data.ts @@ -0,0 +1,31 @@ +import { OEmbedService } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; + +/** + * A data source for the OEmbed that fetches data from a given URL. + * @export + * @class UmbOEmbedServerDataSource + * @implements {RepositoryDetailDataSource} + */ +export class UmbOEmbedServerDataSource { + #host: UmbControllerHost; + + /** + * Creates an instance of UmbOEmbedServerDataSource. + * @param {UmbControllerHost} host + * @memberof UmbOEmbedServerDataSource + */ + constructor(host: UmbControllerHost) { + this.#host = host; + } + + /** + * Fetches markup for the given URL. + * @param {string} unique + * @memberof UmbOEmbedServerDataSource + */ + async getOEmbedQuery({ url, maxWidth, maxHeight }: { url?: string; maxWidth?: number; maxHeight?: number }) { + return tryExecuteAndNotify(this.#host, OEmbedService.getOembedQuery({ url, maxWidth, maxHeight })); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts new file mode 100644 index 0000000000..5a1c0033a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/index.ts @@ -0,0 +1 @@ +export * from './embedded-media/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/embedded-media-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/embedded-media-modal.token.ts index 7eca2cf44c..0fb7df92ca 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/embedded-media-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/token/embedded-media-modal.token.ts @@ -1,31 +1,18 @@ import { UmbModalToken } from './modal-token.js'; -export enum OEmbedStatus { - NotSupported, - Error, - Success, -} - -export interface UmbEmbeddedMediaDimensions { - width: number; - height: number; - constrain?: boolean; -} - -export interface UmbEmbeddedMediaModalData extends UmbEmbeddedMediaDimensions { +export interface UmbEmbeddedMediaModalData extends Partial { url?: string; } -export interface OEmbedResult extends UmbEmbeddedMediaDimensions { - oEmbedStatus: OEmbedStatus; - supportsDimensions: boolean; - markup?: string; +export interface UmbEmbeddedMediaDimensionsModel { + constrain: boolean; + width: number; + height: number; } export interface UmbEmbeddedMediaModalValue extends UmbEmbeddedMediaModalData { - preview?: string; - originalWidth: number; - originalHeight: number; + markup: string; + url: string; } export const UMB_EMBEDDED_MEDIA_MODAL = new UmbModalToken( diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts index 7d389d2281..1cadeaa0fe 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiny-mce/plugins/tiny-mce-embeddedmedia.plugin.ts @@ -6,10 +6,22 @@ export default class UmbTinyMceEmbeddedMediaPlugin extends UmbTinyMcePluginBase constructor(args: TinyMcePluginArguments) { super(args); - this.editor.ui.registry.addButton('umbembeddialog', { + this.editor.ui.registry.addToggleButton('umbembeddialog', { icon: 'embed', tooltip: 'Embed', onAction: () => this.#onAction(), + onSetup: (api) => { + const editor = this.editor; + const onNodeChange = () => { + const selectedElm = editor.selection.getNode(); + api.setActive( + selectedElm.nodeName.toUpperCase() === 'DIV' && selectedElm.classList.contains('umb-embed-holder'), + ); + }; + + editor.on('NodeChange', onNodeChange); + return () => editor.off('NodeChange', onNodeChange); + }, }); } @@ -44,17 +56,18 @@ export default class UmbTinyMceEmbeddedMediaPlugin extends UmbTinyMcePluginBase #insertInEditor(embed: UmbEmbeddedMediaModalValue, activeElement: HTMLElement) { // Wrap HTML preview content here in a DIV with non-editable class of .mceNonEditable // This turns it into a selectable/cutable block to move about + const wrapper = this.editor.dom.create( 'div', { class: 'mceNonEditable umb-embed-holder', 'data-embed-url': embed.url ?? '', - 'data-embed-height': embed.height, - 'data-embed-width': embed.width, + 'data-embed-height': embed.height!, + 'data-embed-width': embed.width!, 'data-embed-constrain': embed.constrain ?? false, contenteditable: false, }, - embed.preview, + embed.markup, ); // Only replace if activeElement is an Embed element.