Feature: oEmbed implementation

This commit is contained in:
Lone Iversen
2024-05-02 23:57:51 +02:00
parent 644d68fbc4
commit 823a42629a
11 changed files with 161 additions and 169 deletions

View File

@@ -1,6 +1,5 @@
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 = [
@@ -11,12 +10,8 @@ export const handlers = [
const heightParam = req.url.searchParams.get('height');
const height = heightParam ? parseInt(heightParam) : 240;
const response: OEmbedResult = {
supportsDimensions: true,
const response: OEmbedResponseModel = {
markup: `<iframe width="${width}" height="${height}" src="https://www.youtube.com/embed/wJNbtYdr-Hg?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen title="Sleep Token - The Summoning"></iframe>`,
oEmbedStatus: OEmbedStatus.Success,
width,
height,
};
return res(ctx.status(200), ctx.json(response));

View File

@@ -1,127 +1,81 @@
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);
@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 = '';
@state()
private _constrain = false;
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._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._loading = 'failed';
return;
}
this.#loading = false;
this.requestUpdate('_model');
this.value = { ...this.value, markup: data.markup, url: this._url };
this._loading = 'success';
}
#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();
//this.#changeSize('width');
}
#onWidthChange(e: InputEvent) {
this._model.width = parseInt((e.target as HTMLInputElement).value, 10);
this.#changeSize('width');
#onHeightChange(e: UUIInputEvent) {
this._height = parseInt(e.target.value as string, 10);
//this.#getPreview();
//this.#changeSize('height');
}
#onHeightChange(e: InputEvent) {
this._model.height = parseInt((e.target as HTMLInputElement).value, 10);
this.#changeSize('height');
#onConstrainChange() {
this._constrain = !this._constrain;
this.value = { ...this.value, constrain: this._constrain };
}
/**
@@ -129,6 +83,7 @@ export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement<
* 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;
@@ -147,19 +102,7 @@ export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement<
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`
@@ -167,69 +110,53 @@ export class UmbEmbeddedMediaModalElement extends UmbModalBaseElement<
<uui-box>
<umb-property-layout label="URL" orientation="vertical">
<div slot="editor">
<uui-input .value=${this._model.url} type="text" @change=${this.#onUrlChange} required="true">
<uui-input .value=${this._url} type="text" @input=${this.#onUrlChange} required="true">
<uui-button
slot="append"
look="primary"
color="positive"
@click=${this.#getPreview}
?disabled=${!this._model.url}
label="Retrieve"></uui-button>
</uui-input>
</div>
</umb-property-layout>
${when(
this.#embedResult?.oEmbedStatus === OEmbedStatus.Success || this._model.a11yInfo,
this._loading !== undefined,
() =>
html` <umb-property-layout label="Preview" orientation="vertical">
<div slot="editor">
${when(this.#loading, () => html`<uui-loader-circle></uui-loader-circle>`)}
${when(this.#embedResult?.markup, () => html`${unsafeHTML(this.#embedResult.markup)}`)}
${when(this._model.info, () => html` <p aria-hidden="true">${this._model.info}</p>`)}
${when(
this._model.a11yInfo,
() => html` <p class="sr-only" role="alert">${this._model.a11yInfo}</p>`,
)}
${when(this._loading === 'waiting', () => html`<uui-loader-circle></uui-loader-circle>`)}
${when(this.value?.markup, () => html`${unsafeHTML(this.value.markup)}`)}
</div>
</umb-property-layout>`,
)}
<umb-property-layout label="Width" orientation="vertical">
<uui-input
slot="editor"
.value=${this._model.width}
type="number"
?disabled=${this.#dimensionControlsDisabled()}
@change=${this.#onWidthChange}></uui-input>
<umb-property-layout label="Max width" orientation="vertical">
<uui-input slot="editor" .value=${this._width} type="number" @change=${this.#onWidthChange}></uui-input>
</umb-property-layout>
<umb-property-layout label="Height" orientation="vertical">
<uui-input
slot="editor"
.value=${this._model.height}
type="number"
?disabled=${this.#dimensionControlsDisabled()}
@change=${this.#onHeightChange}></uui-input>
<umb-property-layout label="Max height" orientation="vertical">
<uui-input slot="editor" .value=${this._height} type="number" @change=${this.#onHeightChange}></uui-input>
</umb-property-layout>
<umb-property-layout label="Constrain" orientation="vertical">
<uui-toggle
slot="editor"
@change=${this.#onConstrainChange}
?disabled=${this.#dimensionControlsDisabled()}
.checked=${this._model.constrain}></uui-toggle>
<uui-toggle slot="editor" @change=${this.#onConstrainChange} .checked=${this._constrain}></uui-toggle>
</umb-property-layout>
</uui-box>
<uui-button slot="actions" id="cancel" label="Cancel" @click=${this.#handleCancel}>Cancel</uui-button>
<uui-button
slot="actions"
id="cancel"
label=${this.localize.term('buttons_confirmActionCancel')}
@click=${() => this.modalContext?.reject()}></uui-button>
<uui-button
slot="actions"
id="submit"
color="positive"
look="primary"
label="Submit"
@click=${this.#handleConfirm}></uui-button>
label=${this.localize.term('buttons_confirmActionConfirm')}
@click=${() => this.modalContext?.submit()}></uui-button>
</umb-body-layout>
`;
}

View File

@@ -0,0 +1 @@
export * from './repository/index.js';

View File

@@ -0,0 +1,13 @@
import { manifests as repositories } from './repository/manifests.js';
import type { ManifestModal } from '@umbraco-cms/backoffice/extension-registry';
const modals: Array<ManifestModal> = [
{
type: 'modal',
alias: 'Umb.Modal.EmbeddedMedia',
name: 'Embedded Media Modal',
element: () => import('./embedded-media-modal.element.js'),
},
];
export const manifests = [...modals, ...repositories];

View File

@@ -0,0 +1,2 @@
export { UmbDocumentCultureAndHostnamesRepository } from './oembed.repository.js';
export { UMB_DOCUMENT_CULTURE_AND_HOSTNAMES_REPOSITORY_ALIAS } from './manifests.js';

View File

@@ -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<ManifestTypes> = [repository];

View File

@@ -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 };
}
}

View File

@@ -0,0 +1,32 @@
import { DocumentService, OEmbedService } from '@umbraco-cms/backoffice/external/backend-api';
import type { OembedData, UpdateDomainsRequestModel } 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 }));
}
}

View File

@@ -0,0 +1 @@
export * from './embedded-media/index.js';

View File

@@ -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<UmbEmbeddedMediaDimensionsModel> {
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<UmbEmbeddedMediaModalData, UmbEmbeddedMediaModalValue>(

View File

@@ -44,17 +44,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.