diff --git a/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml b/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml index ee460e8053..0ec823c46f 100644 --- a/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml +++ b/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ambitious-stone-0033b3603.yml @@ -9,6 +9,9 @@ on: # branches: # - main +env: + NODE_OPTIONS: --max_old_space_size=16384 + jobs: build_and_deploy_job: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') diff --git a/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ashy-bay-09f36a803.yml b/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ashy-bay-09f36a803.yml index 3e9e2ceaaa..76b6ecd96c 100644 --- a/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ashy-bay-09f36a803.yml +++ b/src/Umbraco.Web.UI.Client/.github/workflows/azure-static-web-apps-ashy-bay-09f36a803.yml @@ -9,6 +9,9 @@ on: # branches: # - main +env: + NODE_OPTIONS: --max_old_space_size=16384 + jobs: build_and_deploy_job: if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 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 f806a8eaa1..4456c4f876 100644 --- a/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml +++ b/src/Umbraco.Web.UI.Client/.github/workflows/build_test.yml @@ -16,7 +16,7 @@ on: workflow_dispatch: env: - NODE_OPTIONS: --max_old_space_size=16384 + NODE_OPTIONS: --max_old_space_size=16384 jobs: build: diff --git a/src/Umbraco.Web.UI.Client/.nvmrc b/src/Umbraco.Web.UI.Client/.nvmrc index 2ef3430431..932b2b01d7 100644 --- a/src/Umbraco.Web.UI.Client/.nvmrc +++ b/src/Umbraco.Web.UI.Client/.nvmrc @@ -1 +1 @@ -18.14 +18.15 diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.element.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.element.ts new file mode 100644 index 0000000000..6df72714a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.element.ts @@ -0,0 +1,272 @@ +import { css, html } from 'lit'; +import { UUITextStyles } from '@umbraco-ui/uui-css/lib'; +import { customElement, property, state } from 'lit/decorators.js'; +import { when } from 'lit-html/directives/when.js'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js'; +import { OEmbedResult, OEmbedStatus, UmbEmbeddedMediaModalData, UmbEmbeddedMediaModalResult } from '.'; +import { umbracoPath } from '@umbraco-cms/backoffice/utils'; +import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; +import { UmbModalHandler } from '@umbraco-cms/backoffice/modal'; + +interface UmbEmbeddedMediaModalModel { + url?: string; + info?: string; + a11yInfo?: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + constrain: boolean; +} + +@customElement('umb-embedded-media-modal') +export class UmbEmbeddedMediaModalElement extends UmbLitElement { + static styles = [ + UUITextStyles, + 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-workspace-property-layout:first-child { + padding-top: 0; + } + + umb-workspace-property-layout:last-child { + padding-bottom: 0; + } + + p { + margin-bottom: 0; + } + `, + ]; + + #loading = false; + #embedResult!: OEmbedResult; + + @property({ attribute: false }) + modalHandler?: UmbModalHandler; + + @property({ type: Object }) + data?: UmbEmbeddedMediaModalData; + + #handleConfirm() { + this.modalHandler?.submit({ selection: this.#embedResult }); + } + + #handleCancel() { + this.modalHandler?.reject(); + } + + @state() + private _model: UmbEmbeddedMediaModalModel = { + url: '', + width: 360, + height: 240, + constrain: true, + info: '', + a11yInfo: '', + originalHeight: 240, + originalWidth: 360, + }; + + connectedCallback() { + super.connectedCallback(); + if (this.data?.url) { + Object.assign(this._model, this.data); + this.#getPreview(); + } + } + + async #getPreview() { + this._model.info = ''; + this._model.a11yInfo = ''; + + this.#loading = true; + this.requestUpdate('_model'); + + 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'); + } + + this.#loading = false; + this.requestUpdate('_model'); + } + + #onPreviewFailed(message: string) { + this._model.info = message; + this._model.a11yInfo = message; + } + + #onUrlChange(e: InputEvent) { + this._model.url = (e.target as HTMLInputElement).value; + this.requestUpdate('_model'); + } + + #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(); + } + } + + #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; + } + + render() { + return html` + + + +
+ + + +
+
+ + ${when( + this.#embedResult?.oEmbedStatus === OEmbedStatus.Success || this._model.a11yInfo, + () => html` +
+ ${when(this.#loading, () => html``)} + ${when(this.#embedResult.markup, () => html`${unsafeHTML(this.#embedResult.markup)}`)} + ${when(this._model.info, () => html` `)} + ${when(this._model.a11yInfo, () => html` `)} +
+
` + )} + + + + + + + + + + + + +
+ + Cancel + +
+ `; + } +} + +export default UmbEmbeddedMediaModalElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-embedded-media-modal': UmbEmbeddedMediaModalElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.stories.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.stories.ts new file mode 100644 index 0000000000..5eb102c153 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/embedded-media-modal.stories.ts @@ -0,0 +1,26 @@ +import '../../components/body-layout/body-layout.element'; +import './embedded-media-modal.element'; + +import { Meta } from '@storybook/web-components'; +import { html } from 'lit'; +import { UmbEmbeddedMediaModalData } from '.'; + +export default { + title: 'API/Modals/Layouts/Embedded Media', + component: 'umb-embedded-media-modal', + id: 'umb-embedded-media-modal', +} as Meta; + +const data: UmbEmbeddedMediaModalData = { + url: 'https://youtu.be/wJNbtYdr-Hg', + width: 360, + height: 240, + constrain: true, +}; + +export const Overview = () => html` + + +`; diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/index.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/index.ts new file mode 100644 index 0000000000..94cde2a310 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/embedded-media/index.ts @@ -0,0 +1,35 @@ +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; + +export enum OEmbedStatus { + NotSupported, + Error, + Success, +} + +interface UmbEmbeddedMediaDimensions { + width?: number; + height?: number; + constrain?: boolean; +} + +export interface UmbEmbeddedMediaModalData extends UmbEmbeddedMediaDimensions { + url?: string; +} + +export interface OEmbedResult extends UmbEmbeddedMediaDimensions { + oEmbedStatus: OEmbedStatus; + supportsDimensions: boolean; + markup?: string; +} + +export type UmbEmbeddedMediaModalResult = { + selection: OEmbedResult; +}; + +export const UMB_EMBEDDED_MEDIA_MODAL_TOKEN = new UmbModalToken( + 'Umb.Modal.EmbeddedMedia', + { + type: 'sidebar', + size: 'small', + } +); diff --git a/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/manifests.ts b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/manifests.ts index ac7dfa75d5..bedaf58b4c 100644 --- a/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/backoffice/shared/modals/manifests.ts @@ -31,6 +31,12 @@ const modals: Array = [ name: 'Section Picker Modal', loader: () => import('./section-picker/section-picker-modal.element'), }, + { + type: 'modal', + alias: 'Umb.Modal.EmbeddedMedia', + name: 'Embedded Media Modal', + loader: () => import('./embedded-media/embedded-media-modal.element'), + }, ]; export const manifests = [...modals]; diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts index fbb30e5394..1219928040 100644 --- a/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/browser-handlers.ts @@ -27,6 +27,7 @@ import { handlers as cultureHandlers } from './domains/culture.handlers'; import { handlers as redirectManagementHandlers } from './domains/redirect-management.handlers'; import { handlers as logViewerHandlers } from './domains/log-viewer.handlers'; import { handlers as packageHandlers } from './domains/package.handlers'; +import { handlers as rteEmbedHandlers } from './domains/rte-embed.handlers'; const handlers = [ serverHandlers.serverVersionHandler, @@ -57,6 +58,7 @@ const handlers = [ ...redirectManagementHandlers, ...logViewerHandlers, ...packageHandlers, + ...rteEmbedHandlers, ]; switch (import.meta.env.VITE_UMBRACO_INSTALL_STATUS) { diff --git a/src/Umbraco.Web.UI.Client/src/core/mocks/domains/rte-embed.handlers.ts b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/rte-embed.handlers.ts new file mode 100644 index 0000000000..b99050a812 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/core/mocks/domains/rte-embed.handlers.ts @@ -0,0 +1,17 @@ +import { rest } from "msw"; +import { umbracoPath } from "@umbraco-cms/backoffice/utils"; +import { OEmbedResult, OEmbedStatus } from "../../../backoffice/shared/modals/embedded-media"; + +export const handlers = [ + rest.get(umbracoPath('/rteembed'), (req, res, ctx) => { + const width = req.url.searchParams.get('width') ?? 360; + const height = req.url.searchParams.get('height') ?? 240; + const response: OEmbedResult = { + supportsDimensions: true, + markup: ``, + oEmbedStatus: OEmbedStatus.Success, + }; + + return res(ctx.status(200), ctx.json(response)); + }), +]; \ No newline at end of file