diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 3e0d257163..e5503b23bc 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2484,8 +2484,8 @@ export default { confirmDeleteBlockTypeMessage: 'Are you sure you want to delete the block configuration %0%?', 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 %0% and all the Block configurations of this?', + confirmDeleteBlockGroupTitle: 'Delete group?', + confirmDeleteBlockGroupMessage: 'Are you sure you want to delete group %0%?', 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%'", diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index 8fe49c37ef..1afbe47805 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -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) { +function resolvePlacementAsBlockGrid( + args: UmbSorterResolvePlacementArgs, +) { // 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 gridColumnNumber; + const verticalDirection = relatedStartCol + foundElColumns + currentElementColumns > gridColumnNumber; + return verticalDirection; + /* + let placeAfter = args.horizontalPlaceAfter; + + return { + verticalDirection, + placeAfter, + };*/ } // -------------------------- @@ -96,7 +109,7 @@ const SORTER_CONFIG: UmbSorterConfig { return modelEntry.contentKey; }, - resolvePlacement: resolvePlacementAsGrid, + resolvePlacement: resolvePlacementAsBlockGrid, identifier: 'block-grid-editor', itemSelector: 'umb-block-grid-entry', containerSelector: '.umb-block-grid__layout-container', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-type-configuration/property-editor-ui-block-grid-type-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-type-configuration/property-editor-ui-block-grid-type-configuration.element.ts index ae743a577d..9c3f2f9276 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-type-configuration/property-editor-ui-block-grid-type-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-type-configuration/property-editor-ui-block-grid-type-configuration.element.ts @@ -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; @@ -43,7 +43,6 @@ export class UmbPropertyEditorUIBlockGridTypeConfigurationElement extends UmbLitElement implements UmbPropertyEditorUiElement { - #moveData?: Array; #sorter = new UmbSorterController(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) ?? []; + 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) ?? []; - 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) ?? []; - 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) { + 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)}>` + @change=${(e: Event) => this.#onChange(e, undefined)} + @create=${(e: CustomEvent) => this.#onCreate(e, undefined)}>` : ''} ${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)}> + @change=${(e: Event) => this.#onChange(e, group.key)} + @create=${(e: CustomEvent) => this.#onCreate(e, group.key)}> `, )} `; @@ -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)}> this.#deleteGroup(groupKey)}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts index 3854cea921..5664722449 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-type/components/input-block-type/input-block-type.element.ts @@ -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 = []; + private _value: Array = []; // 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) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts index dceee84e17..1825a1ce2d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts @@ -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'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts index 24c7a48086..d14228cb56 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/common/confirm/confirm-modal.element.ts @@ -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` - - ${this.data?.content} + + ${unsafeHTML(this.localize.string(this.data?.content))} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/index.ts index 70db439d67..8f373e2f08 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/index.ts @@ -1 +1,2 @@ export * from './sorter.controller.js'; +export * from './replacement-resolver-as-grid.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/replacement-resolver-as-grid.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/replacement-resolver-as-grid.function.ts new file mode 100644 index 0000000000..968f5c485a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/replacement-resolver-as-grid.function.ts @@ -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) { + // 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; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts index fa218872da..b90c7c1614 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts @@ -80,21 +80,39 @@ function destroyPreventEvent(element: Element) { //element.removeAttribute('draggable'); } -export type resolvePlacementArgs = { +export type UmbSorterResolvePlacementReturn = + | boolean + | null + | { + placeAfter: boolean; + verticalDirection?: boolean; + }; + +export type UmbSorterResolvePlacementArgs = { 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 = UmbSorterResolvePlacementArgs< + T, + ElementType +>; + type UniqueType = string | symbol | number; /** @@ -180,7 +198,7 @@ type INTERNAL_UmbSorterConfig = { * } * } */ - resolvePlacement?: (argument: resolvePlacementArgs) => boolean | null; + resolvePlacement?: (argument: UmbSorterResolvePlacementArgs) => UmbSorterResolvePlacementReturn; /** * This callback is executed when an item is moved within this container. */ @@ -248,7 +266,7 @@ export class UmbSorterController = []; - #rqaId?: number; + static rqaId?: number; #containerElement!: HTMLElement; #useContainerShadowRoot?: boolean; @@ -260,7 +278,7 @@ export class UmbSorterController(); + #elements = Array(); public get identifier() { return this.#config.identifier; @@ -330,7 +348,6 @@ export class UmbSorterController): 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 this.destroyItem(item)); + this.#elements.forEach((item) => this.destroyItem(item)); } #itemDraggedOver = (e: DragEvent) => { @@ -433,7 +450,11 @@ export class UmbSorterController; @@ -476,8 +497,8 @@ export class UmbSorterController x !== element); + this.#elements = this.#elements.filter((x) => x !== element); } #setupPlaceholderStyle() { @@ -586,9 +607,9 @@ export class UmbSorterController; // 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 { - 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 { 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 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 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 { + elementsInSameRow.map((x) => { if (x.dragRect.right > lastRight) { lastRight = x.dragRect.right; el = x.el; @@ -824,7 +893,7 @@ export class UmbSorterController 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 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 x !== item).length > 0; } - // TODO: Could get item via attr. public async moveItemInModel(newIndex: number, fromCtrl: UmbSorterController) { if (!UmbSorterController.activeItem) { console.error('There is no active item to move'); @@ -940,34 +1049,8 @@ export class UmbSorterController; 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; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts index 7ccb3235bf..dc3b81e63c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts @@ -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 false, + resolvePlacement: UmbSorterResolvePlacementAsGrid, onChange: ({ model }) => { this.selection = model; this.#sortCards(model); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts index efa2e06e9d..cd9abf6633 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts @@ -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(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; } }