Fix: Improve sorter placement algorithm (#18021)

* improve sorting algorithm

* fix block type input

* make confirm modal localizable

* rename method

* clean up

* clean up

* improve code

* Fix creating Block Types in Groups

* remove #moveData

* lint fixes

* remove unused

---------

Co-authored-by: Mads Rasmussen <madsr@hey.com>
This commit is contained in:
Niels Lyngsø
2025-01-20 09:10:50 +01:00
committed by GitHub
parent 4353027655
commit 6c1c851d8a
11 changed files with 331 additions and 196 deletions

View File

@@ -2484,8 +2484,8 @@ export default {
confirmDeleteBlockTypeMessage: 'Are you sure you want to delete the block configuration <strong>%0%</strong>?',
confirmDeleteBlockTypeNotice:
'The content of this block will still be present, editing of this content\n will no longer be available and will be shown as unsupported content.\n ',
confirmDeleteBlockGroupMessage:
'Are you sure you want to delete group <strong>%0%</strong> and all the Block configurations of this?',
confirmDeleteBlockGroupTitle: 'Delete group?',
confirmDeleteBlockGroupMessage: 'Are you sure you want to delete group <strong>%0%</strong>?',
confirmDeleteBlockGroupNotice:
'The content of these Blocks will still be present, editing of this content\n will no longer be available and will be shown as unsupported content.\n ',
blockConfigurationOverlayTitle: "Configuration of '%0%'",

View File

@@ -10,7 +10,11 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html, customElement, state, repeat, css, property, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import '../block-grid-entry/index.js';
import { UmbSorterController, type UmbSorterConfig, type resolvePlacementArgs } from '@umbraco-cms/backoffice/sorter';
import {
UmbSorterController,
type UmbSorterConfig,
type UmbSorterResolvePlacementArgs,
} from '@umbraco-cms/backoffice/sorter';
import {
UmbFormControlMixin,
UmbFormControlValidator,
@@ -23,7 +27,9 @@ import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models';
* @param args
* @returns { null | true }
*/
function resolvePlacementAsGrid(args: resolvePlacementArgs<UmbBlockGridLayoutModel, UmbBlockGridEntryElement>) {
function resolvePlacementAsBlockGrid(
args: UmbSorterResolvePlacementArgs<UmbBlockGridLayoutModel, UmbBlockGridEntryElement>,
) {
// If this has areas, we do not want to move, unless we are at the edge
if (args.relatedModel.areas?.length > 0 && isWithinRect(args.pointerX, args.pointerY, args.relatedRect, -10)) {
return null;
@@ -80,9 +86,16 @@ function resolvePlacementAsGrid(args: resolvePlacementArgs<UmbBlockGridLayoutMod
const relatedStartCol = Math.round(
getInterpolatedIndexOfPositionInWeightMap(relatedStartX, approvedContainerGridColumns),
);
// If the found related element does not have enough room after which for the current element, then we go vertical mode:
return relatedStartCol + (args.horizontalPlaceAfter ? foundElColumns : 0) + currentElementColumns > gridColumnNumber;
const verticalDirection = relatedStartCol + foundElColumns + currentElementColumns > gridColumnNumber;
return verticalDirection;
/*
let placeAfter = args.horizontalPlaceAfter;
return {
verticalDirection,
placeAfter,
};*/
}
// --------------------------
@@ -96,7 +109,7 @@ const SORTER_CONFIG: UmbSorterConfig<UmbBlockGridLayoutModel, UmbBlockGridEntryE
getUniqueOfModel: (modelEntry) => {
return modelEntry.contentKey;
},
resolvePlacement: resolvePlacementAsGrid,
resolvePlacement: resolvePlacementAsBlockGrid,
identifier: 'block-grid-editor',
itemSelector: 'umb-block-grid-entry',
containerSelector: '.umb-block-grid__layout-container',

View File

@@ -1,9 +1,7 @@
import type { UmbBlockTypeWithGroupKey, UmbInputBlockTypeElement } from '../../../block-type/index.js';
import '../../../block-type/components/input-block-type/index.js';
import {
type UmbPropertyEditorUiElement,
UmbPropertyValueChangeEvent,
type UmbPropertyEditorConfigCollection,
import type { UmbBlockTypeWithGroupKey, UmbInputBlockTypeElement } from '@umbraco-cms/backoffice/block-type';
import type {
UmbPropertyEditorUiElement,
UmbPropertyEditorConfigCollection,
} from '@umbraco-cms/backoffice/property-editor';
import {
html,
@@ -30,6 +28,8 @@ import {
} from '@umbraco-cms/backoffice/property';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { umbConfirmModal } from '@umbraco-cms/backoffice/modal';
interface MappedGroupWithBlockTypes extends UmbBlockGridTypeGroupType {
blocks: Array<UmbBlockTypeWithGroupKey>;
@@ -43,7 +43,6 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
extends UmbLitElement
implements UmbPropertyEditorUiElement
{
#moveData?: Array<UmbBlockTypeWithGroupKey>;
#sorter = new UmbSorterController<MappedGroupWithBlockTypes, HTMLElement>(this, {
getUniqueOfElement: (element) => element.getAttribute('data-umb-group-key'),
getUniqueOfModel: (modelEntry) => modelEntry.key!,
@@ -104,8 +103,14 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, async (context) => {
this.#datasetContext = context;
//this.#observeBlocks();
this.#observeBlockGroups();
this.observe(
await this.#datasetContext.propertyValueByAlias('blockGroups'),
(value) => {
this.#blockGroups = (value as Array<UmbBlockGridTypeGroupType>) ?? [];
this.#mapValuesToBlockGroups();
},
'_observeBlockGroups',
);
});
this.#blockTypeWorkspaceModalRegistration = new UmbModalRouteRegistrationController(
@@ -119,24 +124,6 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
});
}
async #observeBlockGroups() {
if (!this.#datasetContext) return;
this.observe(await this.#datasetContext.propertyValueByAlias('blockGroups'), (value) => {
this.#blockGroups = (value as Array<UmbBlockGridTypeGroupType>) ?? [];
this.#mapValuesToBlockGroups();
});
}
// TODO: No need for this, we just got the value via the value property.. [NL]
/*
async #observeBlocks() {
if (!this.#datasetContext) return;
this.observe(await this.#datasetContext.propertyValueByAlias('blocks'), (value) => {
this.value = (value as Array<UmbBlockTypeWithGroupKey>) ?? [];
this.#mapValuesToBlockGroups();
});
}
*/
#mapValuesToBlockGroups() {
if (!this.#blockGroups) return;
// Map blocks that are not in any group, or in a group that does not exist
@@ -152,63 +139,60 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
this.#sorter.setModel(this._groupsWithBlockTypes);
}
#onDelete(e: CustomEvent, groupKey?: string) {
const updatedValues = (e.target as UmbInputBlockTypeElement).value.map((value) => ({ ...value, groupKey }));
const filteredValues = this.#value.filter((value) => value.groupKey !== groupKey);
this.value = [...filteredValues, ...updatedValues];
this.dispatchEvent(new UmbPropertyValueChangeEvent());
}
async #onChange(e: CustomEvent) {
async #onChange(e: Event, groupKey?: string) {
e.stopPropagation();
const element = e.target as UmbInputBlockTypeElement;
const value = element.value;
const value = element.value.map((x) => ({ ...x, groupKey }));
if (!e.detail?.moveComplete) {
// Container change, store data of the new group...
const newGroupKey = element.getAttribute('data-umb-group-key');
const movedItem = e.detail?.item as UmbBlockTypeWithGroupKey;
// Check if item moved back to original group...
if (movedItem.groupKey === newGroupKey) {
this.#moveData = undefined;
} else {
this.#moveData = value.map((block) => ({ ...block, groupKey: newGroupKey }));
}
} else if (e.detail?.moveComplete) {
// Move complete, get the blocks that were in an untouched group
const blocks = this.#value
.filter((block) => !value.find((value) => value.contentElementTypeKey === block.contentElementTypeKey))
.filter(
(block) => !this.#moveData?.find((value) => value.contentElementTypeKey === block.contentElementTypeKey),
);
this.value = this.#moveData ? [...blocks, ...value, ...this.#moveData] : [...blocks, ...value];
this.dispatchEvent(new UmbPropertyValueChangeEvent());
this.#moveData = undefined;
if (groupKey) {
// Update the specific group:
this._groupsWithBlockTypes = this._groupsWithBlockTypes.map((group) => {
if (group.key === groupKey) {
return { ...group, blocks: value };
}
return group;
});
} else {
// Update the not grouped blocks:
this._notGroupedBlockTypes = value;
}
this.#updateValue();
}
#updateValue() {
this.value = [...this._notGroupedBlockTypes, ...this._groupsWithBlockTypes.flatMap((group) => group.blocks)];
this.dispatchEvent(new UmbChangeEvent());
}
#updateBlockGroupsValue(groups: Array<UmbBlockGridTypeGroupType>) {
this.#datasetContext?.setPropertyValue('blockGroups', groups);
}
#onCreate(e: CustomEvent, groupKey?: string) {
const selectedElementType = e.detail.contentElementTypeKey;
if (selectedElementType) {
this.#blockTypeWorkspaceModalRegistration?.open({}, 'create/' + selectedElementType + '/' + (groupKey ?? null));
this.#blockTypeWorkspaceModalRegistration?.open({}, 'create/' + selectedElementType + '/' + (groupKey ?? 'null'));
}
}
// TODO: Implement confirm dialog [NL]
#deleteGroup(groupKey: string) {
// TODO: make one method for updating the blockGroupsDataSetValue: [NL]
// This one that deletes might require the ability to parse what to send as an argument to the method, then a filtering can occur before.
this.#datasetContext?.setPropertyValue(
'blockGroups',
this.#blockGroups?.filter((group) => group.key !== groupKey),
);
async #deleteGroup(groupKey: string) {
const groupName = this.#blockGroups?.find((group) => group.key === groupKey)?.name ?? '';
await umbConfirmModal(this, {
headline: '#blockEditor_confirmDeleteBlockGroupTitle',
content: this.localize.term('#blockEditor_confirmDeleteBlockGroupMessage', [groupName]),
color: 'danger',
confirmLabel: '#general_delete',
});
// If a group is deleted, Move the blocks to no group:
this.value = this.#value.map((block) => (block.groupKey === groupKey ? { ...block, groupKey: undefined } : block));
if (this.#blockGroups) {
this.#updateBlockGroupsValue(this.#blockGroups.filter((group) => group.key !== groupKey));
}
}
#changeGroupName(e: UUIInputEvent, groupKey: string) {
#onGroupNameChange(e: UUIInputEvent, groupKey: string) {
const groupName = e.target.value as string;
// TODO: make one method for updating the blockGroupsDataSetValue: [NL]
this.#datasetContext?.setPropertyValue(
@@ -224,9 +208,8 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
.propertyAlias=${this._alias}
.value=${this._notGroupedBlockTypes}
.workspacePath=${this._workspacePath}
@change=${this.#onChange}
@create=${(e: CustomEvent) => this.#onCreate(e, undefined)}
@delete=${(e: CustomEvent) => this.#onDelete(e, undefined)}></umb-input-block-type>`
@change=${(e: Event) => this.#onChange(e, undefined)}
@create=${(e: CustomEvent) => this.#onCreate(e, undefined)}></umb-input-block-type>`
: ''}
${repeat(
this._groupsWithBlockTypes,
@@ -239,9 +222,8 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
.propertyAlias=${this._alias + '_' + group.key}
.value=${group.blocks}
.workspacePath=${this._workspacePath}
@change=${this.#onChange}
@create=${(e: CustomEvent) => this.#onCreate(e, group.key)}
@delete=${(e: CustomEvent) => this.#onDelete(e, group.key)}></umb-input-block-type>
@change=${(e: Event) => this.#onChange(e, group.key)}
@create=${(e: CustomEvent) => this.#onCreate(e, group.key)}></umb-input-block-type>
</div>`,
)}
</div>`;
@@ -253,7 +235,7 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement
auto-width
label="Group"
.value=${groupName ?? ''}
@change=${(e: UUIInputEvent) => this.#changeGroupName(e, groupKey)}>
@change=${(e: UUIInputEvent) => this.#onGroupNameChange(e, groupKey)}>
<uui-button compact slot="append" label="delete" @click=${() => this.#deleteGroup(groupKey)}>
<uui-icon name="icon-trash"></uui-icon>
</uui-button>

View File

@@ -6,12 +6,14 @@ import { css, html, customElement, property, state, repeat } from '@umbraco-cms/
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property';
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import { UmbDeleteEvent } from '@umbraco-cms/backoffice/event';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import {
UMB_DOCUMENT_TYPE_ITEM_STORE_CONTEXT,
UMB_DOCUMENT_TYPE_PICKER_MODAL,
type UmbDocumentTypePickerModalData,
type UmbDocumentTypePickerModalValue,
} from '@umbraco-cms/backoffice/document-type';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter';
import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type';
import '../block-type-card/index.js';
@@ -27,32 +29,40 @@ export class UmbInputBlockTypeElement<
itemSelector: 'umb-block-type-card',
identifier: 'umb-block-type-sorter',
containerSelector: '#blocks',
resolvePlacement: UmbSorterResolvePlacementAsGrid,
onContainerChange: ({ item, model }) => {
this.dispatchEvent(new CustomEvent('container-change', { detail: { item, model } }));
},
onChange: ({ model }) => {
this._items = model;
this._value = model;
this.dispatchEvent(new UmbChangeEvent());
},
onContainerChange: ({ model, item }) => {
this._items = model;
this.dispatchEvent(new CustomEvent('change', { detail: { item } }));
},
onEnd: () => {
/*onEnd: () => {
// TODO: Investigate if onEnd is called when a container move has been performed, if not then I would say it should be. [NL]
this.dispatchEvent(new CustomEvent('change', { detail: { moveComplete: true } }));
},
this.dispatchEvent(new UmbChangeEvent());
},*/
});
#elementPickerModal: UmbModalRouteRegistrationController<
UmbDocumentTypePickerModalData,
UmbDocumentTypePickerModalValue
>;
@property({ type: Array, attribute: false })
public set value(items) {
this._items = items ?? [];
this.#sorter.setModel(this._items);
this._value = items ?? [];
// Make sure the block types are unique on contentTypeElementKey:
this._value = this._value.filter(
(value, index, self) => self.findIndex((x) => x.contentElementTypeKey === value.contentElementTypeKey) === index,
);
this.#sorter.setModel(this._value);
}
public get value() {
return this._items;
return this._value;
}
/** @deprecated will be removed in v17 */
@property({ type: String })
public set propertyAlias(value: string | undefined) {
//this.#elementPickerModal.setUniquePathValue('propertyAlias', value);
this.#elementPickerModal.setUniquePathValue('propertyAlias', value);
}
public get propertyAlias(): string | undefined {
return undefined;
@@ -65,7 +75,7 @@ export class UmbInputBlockTypeElement<
private _pickerPath?: string;
@state()
private _items: Array<BlockType> = [];
private _value: Array<BlockType> = [];
// TODO: Seems no need to have these initially, then can be retrieved inside the `create` method. [NL]
#datasetContext?: UmbPropertyDatasetContext;
@@ -84,7 +94,8 @@ export class UmbInputBlockTypeElement<
);
});
new UmbModalRouteRegistrationController(this, UMB_DOCUMENT_TYPE_PICKER_MODAL)
this.#elementPickerModal = new UmbModalRouteRegistrationController(this, UMB_DOCUMENT_TYPE_PICKER_MODAL)
.addUniquePaths(['propertyAlias'])
.onSetup(() => {
return {
data: {
@@ -123,8 +134,8 @@ export class UmbInputBlockTypeElement<
}
deleteItem(contentElementTypeKey: string) {
this.value = this.value.filter((x) => x.contentElementTypeKey !== contentElementTypeKey);
this.dispatchEvent(new UmbDeleteEvent());
this._value = this.value.filter((x) => x.contentElementTypeKey !== contentElementTypeKey);
this.dispatchEvent(new UmbChangeEvent());
}
async #onRequestDelete(item: BlockType) {

View File

@@ -1,8 +1,8 @@
import { UmbRefItemElement } from '@umbraco-cms/backoffice/components';
import type {
UmbEntityCreateOptionActionListModalData,
UmbEntityCreateOptionActionListModalValue,
} from './entity-create-option-action-list-modal.token.js';
import { UmbRefItemElement } from '@umbraco-cms/backoffice/components';
import type { ManifestEntityCreateOptionAction } from '@umbraco-cms/backoffice/entity-create-option-action';
import type { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api';

View File

@@ -1,4 +1,4 @@
import { html, customElement, property, css } from '@umbraco-cms/backoffice/external/lit';
import { html, customElement, property, css, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbConfirmModalData, UmbConfirmModalValue, UmbModalContext } from '@umbraco-cms/backoffice/modal';
import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element';
@@ -21,20 +21,20 @@ export class UmbConfirmModalElement extends UmbLitElement {
override render() {
return html`
<uui-dialog-layout class="uui-text" .headline=${this.data?.headline || null}>
${this.data?.content}
<uui-dialog-layout class="uui-text" .headline=${this.localize.string(this.data?.headline) ?? null}>
${unsafeHTML(this.localize.string(this.data?.content))}
<uui-button
slot="actions"
id="cancel"
label=${this.data?.cancelLabel || this.localize.term('buttons_confirmActionCancel')}
label=${this.localize.string(this.data?.cancelLabel ?? '#buttons_confirmActionCancel')}
@click=${this._handleCancel}></uui-button>
<uui-button
slot="actions"
id="confirm"
color=${this.data?.color || 'positive'}
look="primary"
label=${this.data?.confirmLabel || this.localize.term('buttons_confirmActionConfirm')}
label=${this.localize.string(this.data?.confirmLabel ?? '#buttons_confirmActionConfirm')}
@click=${this._handleConfirm}
${umbFocus()}></uui-button>
</uui-dialog-layout>

View File

@@ -1 +1,2 @@
export * from './sorter.controller.js';
export * from './replacement-resolver-as-grid.function.js';

View File

@@ -0,0 +1,20 @@
import type { UmbSorterResolvePlacementArgs } from './sorter.controller.js';
/**
* This function is used to resolve the placement of an item in a simple grid layout.
* @param args
* @returns { null | true }
*/
export function UmbSorterResolvePlacementAsGrid(args: UmbSorterResolvePlacementArgs<unknown>) {
// If we are part of the same Sorter model
if (args.itemIndex !== null && args.relatedIndex !== null) {
// and the pointer is within the related rect
if (args.relatedRect.left < args.pointerX && args.relatedRect.right > args.pointerX) {
// Then we control the placeAfter property, making the active-drag-element allow to be placed at a spot already when just hovering that spot. (This only works when items have the same size)
return {
placeAfter: args.itemIndex < args.relatedIndex,
};
}
}
return false;
}

View File

@@ -80,21 +80,39 @@ function destroyPreventEvent(element: Element) {
//element.removeAttribute('draggable');
}
export type resolvePlacementArgs<T, ElementType extends HTMLElement> = {
export type UmbSorterResolvePlacementReturn =
| boolean
| null
| {
placeAfter: boolean;
verticalDirection?: boolean;
};
export type UmbSorterResolvePlacementArgs<T, ElementType extends HTMLElement = HTMLElement> = {
containerElement: Element;
containerRect: DOMRect;
item: T;
itemIndex: number | null;
element: ElementType;
elementRect: DOMRect;
relatedElement: ElementType;
relatedModel: T;
relatedRect: DOMRect;
relatedIndex: number | null;
placeholderIsInThisRow: boolean;
horizontalPlaceAfter: boolean;
pointerX: number;
pointerY: number;
};
/**
* @deprecated will be removed in v.17, use `UmbSorterResolvePlacementArgs`
*/
export type resolvePlacementArgs<T, ElementType extends HTMLElement = HTMLElement> = UmbSorterResolvePlacementArgs<
T,
ElementType
>;
type UniqueType = string | symbol | number;
/**
@@ -180,7 +198,7 @@ type INTERNAL_UmbSorterConfig<T, ElementType extends HTMLElement> = {
* }
* }
*/
resolvePlacement?: (argument: resolvePlacementArgs<T, ElementType>) => boolean | null;
resolvePlacement?: (argument: UmbSorterResolvePlacementArgs<T, ElementType>) => UmbSorterResolvePlacementReturn;
/**
* This callback is executed when an item is moved within this container.
*/
@@ -248,7 +266,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
#observer;
#model: Array<T> = [];
#rqaId?: number;
static rqaId?: number;
#containerElement!: HTMLElement;
#useContainerShadowRoot?: boolean;
@@ -260,7 +278,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
#dragX = 0;
#dragY = 0;
#items = Array<ElementType>();
#elements = Array<ElementType>();
public get identifier() {
return this.#config.identifier;
@@ -330,7 +348,6 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
setModel(model: Array<T>): void {
if (this.#model) {
// TODO: Some updates might need to be done, as the model is about to change? Do make the changes after setting the model?.. [NL]
this.#model = model;
}
}
@@ -409,7 +426,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
(this.#containerElement as unknown) = undefined;
}
this.#items.forEach((item) => this.destroyItem(item));
this.#elements.forEach((item) => this.destroyItem(item));
}
#itemDraggedOver = (e: DragEvent) => {
@@ -433,7 +450,11 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
return;
} else {
// TODO: Check if dropping here is okay..
// Indication if drop is good:
if (this.updateAllowIndication(UmbSorterController.activeItem) === false) {
console.log('Dropping here was not allowed');
return;
}
// If so lets set the approaching sorter:
UmbSorterController.dropSorter = this as unknown as UmbSorterController<unknown>;
@@ -476,8 +497,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
}
this.#items.push(element);
this.#items = Array.from(new Set(this.#items));
this.#elements.push(element);
this.#elements = Array.from(new Set(this.#elements));
}
destroyItem(element: HTMLElement) {
@@ -491,7 +512,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
(draggableElement as HTMLElement).draggable = false;
this.#items = this.#items.filter((x) => x !== element);
this.#elements = this.#elements.filter((x) => x !== element);
}
#setupPlaceholderStyle() {
@@ -586,9 +607,9 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
UmbSorterController.dropSorter = this as unknown as UmbSorterController<unknown>;
// We must wait one frame before changing the look of the block.
this.#rqaId = requestAnimationFrame(() => {
UmbSorterController.rqaId = requestAnimationFrame(() => {
// It should be okay to use the same rqaId, as the move does not, or is okay not, to happen on first frame/drag-move.
this.#rqaId = undefined;
UmbSorterController.rqaId = undefined;
if (UmbSorterController.activeElement) {
UmbSorterController.activeElement.style.transform = '';
}
@@ -650,9 +671,9 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
});
}
if (this.#rqaId) {
cancelAnimationFrame(this.#rqaId);
this.#rqaId = undefined;
if (UmbSorterController.rqaId) {
cancelAnimationFrame(UmbSorterController.rqaId);
UmbSorterController.rqaId = undefined;
}
UmbSorterController.activeItem = undefined;
@@ -689,19 +710,23 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
const activeDragRect = UmbSorterController.activeDragElement!.getBoundingClientRect();
const insideCurrentRect = isWithinRect(this.#dragX, this.#dragY, activeDragRect);
if (!insideCurrentRect) {
if (this.#rqaId === undefined) {
this.#rqaId = requestAnimationFrame(this.#updateDragMove);
if (UmbSorterController.rqaId === undefined) {
UmbSorterController.rqaId = requestAnimationFrame(this.#updateDragMove);
}
}
}
}
#updateDragMove = () => {
this.#rqaId = undefined;
UmbSorterController.rqaId = undefined;
if (!UmbSorterController.activeElement || !UmbSorterController.activeItem) {
return;
}
if ((UmbSorterController.dropSorter as any) !== this) {
throw new Error('Drop sorter is not this sorter');
}
// Maybe no need to check this twice, like we do it before the RAF an inside it, I think its fine to choose one of them.
const currentElementRect = UmbSorterController.activeElement.getBoundingClientRect();
const insideCurrentRect = isWithinRect(this.#dragX, this.#dragY, currentElementRect);
@@ -744,6 +769,10 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
elementsInSameRow.forEach((sameRow) => {
const centerX = sameRow.dragRect.left + sameRow.dragRect.width * 0.5;
const distance = Math.abs(this.#dragX - centerX);
/*const distance = Math.min(
Math.abs(this.#dragX - sameRow.dragRect.left),
Math.abs(this.#dragX - sameRow.dragRect.right),
);*/
if (distance < lastDistance) {
foundEl = sameRow.el as HTMLElement;
foundElDragRect = sameRow.dragRect;
@@ -752,6 +781,11 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
});
let activeIndex: number | null = this.#model.indexOf(UmbSorterController.activeItem);
if (activeIndex === -1) {
activeIndex = null;
}
if (foundEl) {
// If we are on top or closest to our self, we should not do anything.
if (foundEl === UmbSorterController.activeElement) {
@@ -763,21 +797,35 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
throw new Error('Could not find model of found element');
}
// Indication if drop is good:
if (this.updateAllowIndication(UmbSorterController.activeItem) === false) {
return;
let relatedIndex: number | null = this.#model.indexOf(foundModel);
if (relatedIndex === -1) {
relatedIndex = null;
}
const verticalDirection: boolean | null = this.#config.resolvePlacement
if (activeIndex !== null && relatedIndex !== null) {
// We have both indexes, aka. both elements are in this list.
const widthDiff = Math.max(foundElDragRect.width - currentElementRect.width, 0);
if (activeIndex < relatedIndex && foundElDragRect.left + widthDiff < this.#dragX) {
// If we are located before and we are just enough over to get located after, then lets do it already.
placeAfter = true;
} else if (activeIndex > relatedIndex && foundElDragRect.right - widthDiff > this.#dragX) {
// If we are located after and we are just enough over to get located before, then lets do it already.
placeAfter = false;
}
}
const placementResult: UmbSorterResolvePlacementReturn = this.#config.resolvePlacement
? this.#config.resolvePlacement({
containerElement: this.#containerElement,
containerRect: currentContainerRect,
item: UmbSorterController.activeItem,
itemIndex: activeIndex,
element: UmbSorterController.activeElement as ElementType,
elementRect: currentElementRect,
relatedElement: foundEl,
relatedModel: foundModel,
relatedRect: foundElDragRect,
relatedIndex: relatedIndex,
placeholderIsInThisRow: placeholderIsInThisRow,
horizontalPlaceAfter: placeAfter,
pointerX: this.#dragX,
@@ -785,20 +833,41 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
})
: true;
if (verticalDirection === null) {
// The resolvePlacement has chosen to back out of this move.
if (placementResult === null) {
// The resolvePlacement method has chosen to back out of this move.
return;
}
if (verticalDirection) {
placeAfter = this.#dragY > foundElDragRect.top + foundElDragRect.height * 0.5;
let verticalDirection = true;
if (typeof placementResult === 'object') {
verticalDirection = placementResult.verticalDirection ?? false;
placeAfter = placementResult.placeAfter;
} else {
verticalDirection = placementResult ?? false;
if (verticalDirection === true) {
// Lets check if we should place after or before, based on a vertical algortihm.
placeAfter = this.#dragY > foundElDragRect.top + foundElDragRect.height * 0.5;
// There is room for improvements, if we are in the same model:
if (activeIndex !== null && relatedIndex !== null) {
// We have both indexes, aka. both elements are in this list.
const heightDiff = Math.max(foundElDragRect.height - currentElementRect.height, 0);
if (activeIndex < relatedIndex && this.#dragY > foundElDragRect.top + heightDiff) {
// If active is located above and we are just enough above to get located after, then lets do it already.
placeAfter = true;
} else if (activeIndex > relatedIndex && this.#dragY < foundElDragRect.bottom - heightDiff) {
// If active is located below and we are just enough above to get located before, then lets do it already.
placeAfter = false;
}
}
}
}
if (verticalDirection) {
if (verticalDirection === true) {
let el;
if (placeAfter === false) {
let lastLeft = foundElDragRect.left;
elementsInSameRow.findIndex((x) => {
elementsInSameRow.map((x) => {
if (x.dragRect.left < lastLeft) {
lastLeft = x.dragRect.left;
el = x.el;
@@ -806,7 +875,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
});
} else {
let lastRight = foundElDragRect.right;
elementsInSameRow.findIndex((x) => {
elementsInSameRow.map((x) => {
if (x.dragRect.right > lastRight) {
lastRight = x.dragRect.right;
el = x.el;
@@ -824,7 +893,7 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
return;
}
// We skipped the above part cause we are above or below container, or within an empty container:
// We skipped the above part cause we are above or below container, or within an empty container, or in a blank space:
// Indication if drop is good:
if (this.updateAllowIndication(UmbSorterController.activeItem) === false) {
@@ -838,9 +907,57 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
this.#moveElementTo(0);
} else if (this.#dragY > currentContainerRect.bottom) {
this.#moveElementTo(-1);
} else {
// There was no target, but we are still inside. aka. in a vertical gap/gutter/blankspace.
if (this.#model.length > 1 && activeIndex !== null) {
const belowActive = this.#dragY > currentElementRect.bottom;
const foundTarget =
belowActive === false
? this.#findIndexToMoveTo(0, activeIndex)
: this.#findIndexToMoveTo(activeIndex, this.#model.length);
if (foundTarget) {
this.#moveElementTo(foundTarget);
}
}
}
};
#findIndexToMoveTo(a: number, b: number): number | undefined {
if (a === b) {
return a;
}
const halfWay = a + Math.round((b - a) * 0.5);
// if we hit one of the points, then lets just move to the other point.
if (halfWay === a || halfWay === b) {
return b;
}
const belowHalf = this.#isPointerBelowTargetElement(halfWay);
if (belowHalf === null) {
throw new Error('Could not determine if below target');
}
if (belowHalf) {
return this.#findIndexToMoveTo(halfWay, b);
} else {
return this.#findIndexToMoveTo(a, halfWay);
}
}
#isPointerBelowTargetElement(targetIndex: number) {
if (targetIndex > 0 && targetIndex < this.#model.length) {
const element = this.getElementOfItem(this.#model[targetIndex]);
if (element) {
// Below this one == true, otherwise false.
return this.#dragY > element?.getBoundingClientRect().bottom;
}
}
return null;
}
//
async #moveElementTo(newIndex: number) {
if (!UmbSorterController.activeElement || !UmbSorterController.activeSorter) {
@@ -851,6 +968,9 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
if (!requestingSorter) {
throw new Error('Could not find requestingSorter');
}
if ((requestingSorter as any) !== this) {
throw new Error('Requesting sorter is not this sorter');
}
// If same container and same index, do nothing:
if (requestingSorter === UmbSorterController.activeSorter && UmbSorterController.activeIndex === newIndex) return;
@@ -872,6 +992,16 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
return this.#model.find((entry: T) => elementUnique === this.#config.getUniqueOfModel(entry));
}
public getElementOfItem(item: T) {
const unique = this.#config.getUniqueOfModel(item);
if (unique === undefined) {
console.error('Sorter could not find unique of item', item);
//throw new Error('Sorter could not find unique of item');
return;
}
return this.#elements.find((element) => unique === this.#config.getUniqueOfElement(element));
}
public async removeItem(item: T) {
if (!item) {
return false;
@@ -891,32 +1021,11 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
return false;
}
/*
public async insertItem(item: T, newIndex: number = 0) {
if (!item) {
return false;
}
if (this.#config.performItemInsert) {
const result = await this.#config.performItemInsert({ item, newIndex });
if (result === false) {
return false;
}
} else {
const newModel = [...this.#model];
newModel.splice(newIndex, 0, item);
this.#model = newModel;
this.#config.onChange?.({ model: newModel, item });
}
return false;
}
*/
public hasOtherItemsThan(item: T) {
return this.#model.filter((x) => x !== item).length > 0;
}
// TODO: Could get item via attr.
public async moveItemInModel(newIndex: number, fromCtrl: UmbSorterController<T, ElementType>) {
if (!UmbSorterController.activeItem) {
console.error('There is no active item to move');
@@ -940,34 +1049,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
const localMove = fromCtrl === (this as any);
if (localMove) {
// Local move:
const oldIndex = this.#model.indexOf(item);
if (oldIndex === -1) {
console.error('Could not find item in model when performing internal move', this.getHostElement(), this.#model);
return false;
}
if (this.#config.performItemMove) {
const result = await this.#config.performItemMove({ item, newIndex, oldIndex });
if (result === false) {
return false;
}
} else {
const newModel = [...this.#model];
newModel.splice(oldIndex, 1);
if (oldIndex <= newIndex) {
newIndex--;
}
newModel.splice(newIndex, 0, item);
this.#model = newModel;
this.#config.onChange?.({ model: newModel, item });
}
UmbSorterController.activeIndex = newIndex;
} else {
// Not a local move:
if (!localMove) {
// Not a local move, so we have to switch container to continue:
if ((await fromCtrl.removeItem(item)) !== true) {
console.error('Sync could not remove item when moving to a new container');
@@ -997,6 +1080,35 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
UmbSorterController.dropSorter = this as unknown as UmbSorterController<unknown>;
UmbSorterController.activeIndex = newIndex;
}
return true;
}
if (localMove) {
// Local move:
const oldIndex = this.#model.indexOf(item);
if (oldIndex === -1) {
console.error('Could not find item in model when performing internal move', this.getHostElement(), this.#model);
return false;
}
if (this.#config.performItemMove) {
const result = await this.#config.performItemMove({ item, newIndex, oldIndex });
if (result === false) {
return false;
}
} else {
const newModel = [...this.#model];
newModel.splice(oldIndex, 1);
if (oldIndex <= newIndex) {
newIndex--;
}
newModel.splice(newIndex, 0, item);
this.#model = newModel;
this.#config.onChange?.({ model: newModel, item });
}
UmbSorterController.activeIndex = newIndex;
}
return true;

View File

@@ -14,7 +14,7 @@ import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter';
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
@@ -33,8 +33,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin<string | undefined
identifier: 'Umb.SorterIdentifier.InputMedia',
itemSelector: 'uui-card-media',
containerSelector: '.container',
/** TODO: This component probably needs some grid-like logic for resolve placement... [LI] */
resolvePlacement: () => false,
resolvePlacement: UmbSorterResolvePlacementAsGrid,
onChange: ({ model }) => {
this.selection = model;
this.#sortCards(model);

View File

@@ -8,7 +8,7 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter';
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router';
@@ -25,9 +25,7 @@ type UmbRichMediaCardModel = {
isTrashed?: boolean;
};
const elementName = 'umb-input-rich-media';
@customElement(elementName)
@customElement('umb-input-rich-media')
export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, '') {
#sorter = new UmbSorterController<UmbMediaPickerPropertyValue>(this, {
getUniqueOfElement: (element) => {
@@ -39,9 +37,8 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
identifier: 'Umb.SorterIdentifier.InputRichMedia',
itemSelector: 'uui-card-media',
containerSelector: '.container',
// TODO: This component probably needs some grid-like logic for resolve placement... [LI]
// TODO: You can also use verticalDirection? [NL]
resolvePlacement: () => false,
//resolvePlacement: (args) => args.pointerX < args.relatedRect.left + args.relatedRect.width * 0.5,
resolvePlacement: UmbSorterResolvePlacementAsGrid,
onChange: ({ model }) => {
this.#items = model;
this.#sortCards(model);
@@ -454,6 +451,6 @@ export default UmbInputRichMediaElement;
declare global {
interface HTMLElementTagNameMap {
[elementName]: UmbInputRichMediaElement;
'umb-input-rich-media': UmbInputRichMediaElement;
}
}