Merge branch 'main' into feature/section-alias-condition-one-of

This commit is contained in:
Niels Lyngsø
2024-05-03 22:04:19 +02:00
committed by GitHub
12 changed files with 204 additions and 235 deletions

View File

@@ -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: `<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

@@ -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`<div class="tab-actions">
return html`<div>
${this._compositionRepositoryAlias
? html`<uui-button
look="outline"
@@ -458,7 +458,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
label=${tab.name && tab.name !== '' ? tab.name : 'unnamed'}
.active=${tabActive}
href=${path}
data-umb-tab-id=${ifDefined(tab.id)}>
data-umb-tab-id=${ifDefined(tab.id)}
?sortable=${ownedTab}>
${this.renderTabInner(tab, tabActive, ownedTab)}
</uui-tab>`;
}
@@ -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 {

View File

@@ -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`
<umb-body-layout headline="Embed">
<uui-box>
<umb-property-layout label="URL" orientation="vertical">
<umb-property-layout label=${this.localize.term('general_url')} orientation="vertical">
<div slot="editor">
<uui-input .value=${this._model.url} type="text" @change=${this.#onUrlChange} required="true">
<uui-input id="url" .value=${this._url} @input=${this.#onUrlChange} required="true">
<uui-button
slot="append"
look="primary"
color="positive"
@click=${this.#getPreview}
?disabled=${!this._model.url}
label="Retrieve"></uui-button>
label=${this.localize.term('general_retrieve')}></uui-button>
</uui-input>
</div>
</umb-property-layout>
${when(
this.#embedResult?.oEmbedStatus === OEmbedStatus.Success || this._model.a11yInfo,
this.#validUrl !== undefined,
() =>
html` <umb-property-layout label="Preview" orientation="vertical">
html` <umb-property-layout label=${this.localize.term('general_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">
<umb-property-layout label=${this.localize.term('general_width')} orientation="vertical">
<uui-input
slot="editor"
.value=${this._model.width}
.value=${this._width}
type="number"
?disabled=${this.#dimensionControlsDisabled()}
@change=${this.#onWidthChange}></uui-input>
@change=${this.#onWidthChange}
?disabled=${this.#validUrl ? false : true}></uui-input>
</umb-property-layout>
<umb-property-layout label="Height" orientation="vertical">
<umb-property-layout label=${this.localize.term('general_height')} orientation="vertical">
<uui-input
slot="editor"
.value=${this._model.height}
.value=${this._height}
type="number"
?disabled=${this.#dimensionControlsDisabled()}
@change=${this.#onHeightChange}></uui-input>
@change=${this.#onHeightChange}
?disabled=${this.#validUrl ? false : true}></uui-input>
</umb-property-layout>
<umb-property-layout label="Constrain" orientation="vertical">
<umb-property-layout label=${this.localize.term('general_constrainProportions')} orientation="vertical">
<uui-toggle
slot="editor"
@change=${this.#onConstrainChange}
?disabled=${this.#dimensionControlsDisabled()}
.checked=${this._model.constrain}></uui-toggle>
.checked=${this.value?.constrain ?? false}></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>
`;
}
@@ -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;
}
`,
];
}

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 { UmbOEmbedRepository } from './oembed.repository.js';
export { UMB_OEMBED_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,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 }));
}
}

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

@@ -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.