+ Could not render component of type: @(item.Content.ContentType.Alias)
+
+ This likely happened because the partial view @partialViewName could not be found.
+
+ }
+ }
+
+ }
+
diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html
new file mode 100644
index 0000000000..523b0fe7d3
--- /dev/null
+++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html
@@ -0,0 +1,37 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html
new file mode 100644
index 0000000000..521d7c9b09
--- /dev/null
+++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html
@@ -0,0 +1,92 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html
new file mode 100644
index 0000000000..dbe157cf84
--- /dev/null
+++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html
@@ -0,0 +1,34 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html
new file mode 100644
index 0000000000..ccc3af22e8
--- /dev/null
+++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html
@@ -0,0 +1,50 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Cms/Umbraco.Cms.csproj b/src/Umbraco.Cms/Umbraco.Cms.csproj
index cf0d6aeacc..843540b7ac 100644
--- a/src/Umbraco.Cms/Umbraco.Cms.csproj
+++ b/src/Umbraco.Cms/Umbraco.Cms.csproj
@@ -19,4 +19,18 @@
+
+
+
+
+
+
+
+ $(MSBuildThisFileDirectory)appsettings-schema.json
+ $(MSBuildThisFileDirectory)..\JsonSchema\
+
+
+
+
+
diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs
index 2bb53b3299..a42f2d198e 100644
--- a/src/Umbraco.Core/Constants-PropertyEditors.cs
+++ b/src/Umbraco.Core/Constants-PropertyEditors.cs
@@ -41,6 +41,11 @@ public static partial class Constants
public const string BlockList = "Umbraco.BlockList";
///
+ /// Block Grid.
+ ///
+ public const string BlockGrid = "Umbraco.BlockGrid";
+
+ ///
/// CheckBox List.
///
public const string CheckBoxList = "Umbraco.CheckBoxList";
diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml
index 93b0f95af2..387f1ef652 100644
--- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml
+++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml
@@ -1962,6 +1962,7 @@ Mange hilsner fra Umbraco robotten
Selvvalgt validering%1% mere.]]>%1% for mange.]]>
+ Ét eller flere områder lever ikke op til kravene for antal indholdselementer.Slå URL tracker fra
@@ -2184,6 +2185,12 @@ Mange hilsner fra Umbraco robotten
Indholdet vil stadigt eksistere, men redigering af dette indhold vil ikke
være muligt. Indholdet vil blive vist som ikke understøttet indhold.
+
+
+ %0% og blok konfigurationer?]]>
+ Indholdet af gruppens blokke vil stadigt eksistere, men redigering af dette indhold vil ikke
+ være muligt. Indholdet vil blive vist som ikke understøttet indhold.
+ Kan ikke redigeres fordi elementtypen ikke eksisterer.Billede
@@ -2193,6 +2200,7 @@ Mange hilsner fra Umbraco robotten
IndstillingerAvanceretSkjul indholdseditoren
+ Skjul indholds redigerings knappen samt indholdseditoren i Blok Redigerings vinduetDu har lavet ændringer til dette indhold. Er du sikker på at du vil kassere dem?Annuller oprettelse?
@@ -2201,6 +2209,64 @@ Mange hilsner fra Umbraco robotten
Tilføj indholdTilføj %0%Feltet %0% bruger editor %1% som ikke er supporteret for blokke.
+ Fokusér på den ydre blok
+ Identifikation
+ Validering
+ %0% skal tilføjes minimum %2% gang(e).]]>
+ %0% må maksimalt tilføjes %3% gang(e).]]>
+ Antal blokke
+ Tillad kun specifikke blok-typer
+ Tilladte blok-typer
+ Vælg de blok-typer, der er tilladt i dette område, og evt. også hvor mange af hver type, redaktørerne skal tilføje til området.
+ Er du sikker på, at du vil slette dette område?
+ Alle blokke, der er oprettet i dette område, vil blive slettet.
+ Layout-opsætning
+ Struktur
+ Størrelses opsætning
+ Tilgængelige kolonne-størrelser
+ Vælg de forskellige antal kolonner denne blok må optage i layoutet. Dette forhindre ikke blokken i at optræde i et mindre område.
+ TIlgængelige række-størrelser
+ Vælg hvor mange rækker denne blok på optage i layoutet.
+ Tillad på rodniveay
+ Gør denne blok tilgængelig i layoutets rodniveau. Hvis dette ikke er valgt, kan denne blok kun bruges inden for andre blokkes definerede områder.
+ Blok-områder
+ Layout-kolonner
+ Vælg hvor mange layout-kolonnner der skal være tilgængelig for blokkens områder. Hvis der ikke er valgt noget her, benyttes det antal layout-kolonner der er valgt for hele layoutet.
+ Opsætning af områder
+ Hvis det skal være muligt at indsætte nye blokke indeni denne blok, skal der oprettes ét eller flere områder til at indsætte de nye blokke i.
+ Ikke tilladt placering.
+ Standart layout stylesheet
+ Ikke tilladt indhold blev afvist
+
+
+
+
+ Påtving placering i venstre side
+ Påtving placering i højre side
+ Fjern påtvungen placering i venstre side
+ Fjern påtvungen placering i højre side
+ Skift påtvungen placering i venstre side
+ Skift påtvungen placering i højre side
+ Træk for at skalere
+ Tilføj indhold label
+ Overskriv labellen for tilføj indholds knappen i dette område.
+ Tilføj skalerings muligheder
+ Tilføj områder
+ Tilføj katalog udseende
+ Tilføj Blok
+ Tilføj gruppe
+ Tilføj gruppe eller block
+ Sæt minimum krav for denne tilladelse
+ Set maksimum krav for denne tilladelse
+ Blok
+ Blok
+ Indstillinger
+ Områder
+ Avanceret
+ Tilladelser
+ Installer demo konfiguration
+ Dette indeholder Blokke for Overskrift, Beriget-Tekst, Billede og To-Koloners-Layout.]]>
+ InstallerHvad er Indholdsskabeloner?
diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml
index a686b4eb56..82311e4311 100644
--- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml
+++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml
@@ -2275,6 +2275,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont
Custom validation%1% more.]]>%1% too many.]]>
+ The content amount requirements are not met for one or more areas.
-
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less b/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less
index 1d8588caca..33e1156648 100644
--- a/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less
+++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less
@@ -3,10 +3,6 @@ umb-property-info-button {
display: inline-block;
vertical-align: text-bottom;
- .control-label + & {
- margin-left: -8px;
- }
-
> .__button {
position: relative;
display: inline-flex;
@@ -75,6 +71,7 @@ umb-property-info-button {
// hide umb-info-button if inside umb-property
.umb-property umb-property-info-button {
opacity: 0;
+ transition: opacity 120ms;
}
.umb-property:focus-within umb-property-info-button,
@@ -90,6 +87,7 @@ umb-property-info-button {
// hide umb-info-button if inside
.umb-control-group umb-property-info-button {
opacity: 0;
+ transition: opacity 120ms;
}
.umb-control-group:focus-within umb-property-info-button,
@@ -101,3 +99,18 @@ umb-property-info-button {
.umb-control-group:hover .umb-control-group:not(:hover) umb-property-info-button {
opacity: 0;
}
+
+
+
+.umb-group-panel > .umb-group-panel__header umb-property-info-button {
+ opacity: 0;
+}
+.umb-group-panel > .umb-group-panel__header umb-property-info-button:focus {
+ opacity: 1;
+}
+.umb-group-panel:focus-within,
+.umb-group-panel:hover {
+ > .umb-group-panel__header umb-property-info-button {
+ opacity: 1;
+ }
+}
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html b/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html
index 594f976401..b4422dc49e 100644
--- a/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html
+++ b/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html
@@ -52,7 +52,7 @@
-
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html
new file mode 100644
index 0000000000..05432fa862
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html
@@ -0,0 +1,4 @@
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html
new file mode 100644
index 0000000000..a4efb41717
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js
new file mode 100644
index 0000000000..2a77e81b5c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js
@@ -0,0 +1,29 @@
+(function () {
+ 'use strict';
+
+ function GridInlineBlockEditor($scope, $element) {
+
+ const vm = this;
+
+ vm.$onInit = function() {
+ const host = $element[0].getRootNode();
+
+ console.log(document.styleSheets)
+
+ for (const stylesheet of document.styleSheets) {
+
+ console.log(stylesheet);
+ const styleEl = document.createElement('link');
+ styleEl.setAttribute('rel', 'stylesheet');
+ styleEl.setAttribute('type', stylesheet.type);
+ styleEl.setAttribute('href', stylesheet.href);
+
+ host.appendChild(styleEl);
+ }
+ }
+
+ }
+
+ angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockEditor.GridInlineBlockEditor", GridInlineBlockEditor);
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html
new file mode 100644
index 0000000000..ca12c9dd98
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html
@@ -0,0 +1,93 @@
+
+ This content is no longer supported in this context.
+ You might want to remove this block, or contact your developer to take actions for making this block available again.
+ This will add basic Blocks and help you get started with the Block Grid Editor. You'll get Blocks for Headline, Rich Text, Image, as well as a Two Column Layout.
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html
new file mode 100644
index 0000000000..c288afc3ff
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html
@@ -0,0 +1,68 @@
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html
new file mode 100644
index 0000000000..097e4780a0
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html
new file mode 100644
index 0000000000..b4ef7b9266
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Minimum %0% entries, needs %1% more.
+
+
+
+
+
+
+ Maximum %0% entries, %1% too many.
+
+
+
+
+
+
+
+ %0% must be present between %2% – %3% times.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html
new file mode 100644
index 0000000000..9c8860eaa5
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
/
+
+ {{vm.layoutEntry.$block.label}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{vm.layoutEntry.columnSpan}} x {{vm.layoutEntry.rowSpan}}
+
+
+
+
+
+
+
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html
new file mode 100644
index 0000000000..767be6d559
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Minimum %0% entries, needs %1% more.
+
+ >
+
+
+
+ Maximum %0% entries, %1% too many.
+
+
+
+
+
+
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less
new file mode 100644
index 0000000000..be3d1cc9ec
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less
@@ -0,0 +1,4 @@
+.umb-block-grid__wrapper {
+ position: relative;
+ max-width: 1200px;
+}
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js
new file mode 100644
index 0000000000..57ddea9757
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js
@@ -0,0 +1,1287 @@
+(function () {
+ "use strict";
+
+ function GetAreaAtBlock(parentBlock, areaKey) {
+ if(parentBlock != null) {
+ var area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if(!area) {
+ return null;
+ }
+
+ return area;
+ }
+ return null;
+ }
+
+
+ /**
+ * @ngdoc directive
+ * @name umbraco.directives.directive:umbBlockGridPropertyEditor
+ * @function
+ *
+ * @description
+ * The component for the block grid property editor.
+ */
+ angular
+ .module("umbraco")
+ .component("umbBlockGridPropertyEditor", {
+ templateUrl: "views/propertyeditors/blockgrid/umb-block-grid-property-editor.html",
+ controller: BlockGridController,
+ controllerAs: "vm",
+ bindings: {
+ model: "="
+ },
+ require: {
+ propertyForm: "^form",
+ umbProperty: "?^umbProperty",
+ umbVariantContent: '?^^umbVariantContent',
+ umbVariantContentEditors: '?^^umbVariantContentEditors',
+ umbElementEditorContent: '?^^umbElementEditorContent',
+ valFormManager: '?^^valFormManager'
+ }
+ });
+ function BlockGridController($element, $attrs, $scope, $timeout, $q, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper, eventsService, assetsService, umbRequestHelper) {
+
+ var unsubscribe = [];
+ var modelObject;
+
+ // Property actions:
+ var copyAllBlocksAction = null;
+ var deleteAllBlocksAction = null;
+
+ var liveEditing = true;
+
+ var shadowRoot;
+
+ var vm = this;
+
+ vm.readonly = false;
+
+ $attrs.$observe('readonly', (value) => {
+ vm.readonly = value !== undefined;
+
+ vm.blockEditorApi.readonly = vm.readonly;
+
+ if (deleteAllBlocksAction) {
+ deleteAllBlocksAction.isDisabled = vm.readonly;
+ }
+ });
+
+ vm.loading = true;
+
+ vm.currentBlockInFocus = null;
+ vm.setBlockFocus = function (block) {
+ if (vm.currentBlockInFocus !== null) {
+ vm.currentBlockInFocus.focus = false;
+ }
+ vm.currentBlockInFocus = block;
+ block.focus = true;
+ };
+
+ vm.showAreaHighlight = function(parentBlock, areaKey) {
+ const area = GetAreaAtBlock(parentBlock, areaKey)
+ if(area) {
+ area.$highlight = true;
+ }
+ }
+ vm.hideAreaHighlight = function(parentBlock, areaKey) {
+ const area = GetAreaAtBlock(parentBlock, areaKey)
+ if(area) {
+ area.$highlight = false;
+ }
+ }
+
+ vm.supportCopy = clipboardService.isSupported();
+ vm.clipboardItems = [];
+ unsubscribe.push(eventsService.on("clipboardService.storageUpdate", updateClipboard));
+ unsubscribe.push($scope.$on("editors.content.splitViewChanged", (event, eventData) => {
+ var compositeId = vm.umbVariantContent.editor.compositeId;
+ if(eventData.editors.some(x => x.compositeId === compositeId)) {
+ updateAllBlockObjects();
+ }
+ }));
+
+ vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model.
+ vm.availableBlockTypes = []; // Available block entries of this property editor.
+ vm.labels = {};
+ vm.options = {
+ createFlow: false
+ };
+
+ localizationService.localizeMany(["grid_addElement", "content_createEmpty", "blockEditor_addThis"]).then(function (data) {
+ vm.labels.grid_addElement = data[0];
+ vm.labels.content_createEmpty = data[1];
+ vm.labels.blockEditor_addThis = data[2]
+ });
+
+ vm.$onInit = function() {
+
+
+ //listen for form validation changes
+ vm.valFormManager.onValidationStatusChanged(function (evt, args) {
+ vm.showValidation = vm.valFormManager.showValidation;
+ });
+ //listen for the forms saving event
+ unsubscribe.push($scope.$on("formSubmitting", function (ev, args) {
+ vm.showValidation = true;
+ }));
+
+ //listen for the forms saved event
+ unsubscribe.push($scope.$on("formSubmitted", function (ev, args) {
+ vm.showValidation = false;
+ }));
+
+ if (vm.umbProperty && !vm.umbVariantContent) {// if we don't have vm.umbProperty, it means we are in the DocumentTypeEditor.
+ // not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope
+ // inheritance is (i.e.infinite editing)
+ var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "umbVariantContentController");
+ vm.umbVariantContent = found ? found.vm : null;
+ if (!vm.umbVariantContent) {
+ throw "Could not find umbVariantContent in the $scope chain";
+ }
+ }
+
+ // set the onValueChanged callback, this will tell us if the block grid model changed on the server
+ // once the data is submitted. If so we need to re-initialize
+ vm.model.onValueChanged = onServerValueChanged;
+
+ liveEditing = vm.model.config.useLiveEditing;
+
+ vm.validationLimit = vm.model.config.validationLimit;
+ vm.gridColumns = vm.model.config.gridColumns || 12;
+ vm.createLabel = vm.model.config.createLabel || "";
+ vm.blockGroups = vm.model.config.blockGroups;
+ vm.uniqueEditorKey = String.CreateGuid();
+
+ vm.editorWrapperStyles = {};
+
+ if (vm.model.config.maxPropertyWidth) {
+ vm.editorWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth;
+ }
+
+ if (vm.model.config.layoutStylesheet) {
+ vm.layoutStylesheet = umbRequestHelper.convertVirtualToAbsolutePath(vm.model.config.layoutStylesheet);
+ } else {
+ vm.layoutStylesheet = "assets/css/umbraco-blockgridlayout.css";
+ }
+
+ // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated.
+ if(typeof vm.model.value !== 'object' || vm.model.value === null) {// testing if we have null or undefined value or if the value is set to another type than Object.
+ vm.model.value = {};
+ }
+
+ var scopeOfExistence = $scope;
+ if(vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) {
+ scopeOfExistence = vm.umbVariantContentEditors.getScope();
+ } else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) {
+ scopeOfExistence = vm.umbElementEditorContent.getScope();
+ }
+
+ copyAllBlocksAction = {
+ labelKey: "clipboard_labelForCopyAllEntries",
+ labelTokens: [vm.model.label],
+ icon: "documents",
+ method: requestCopyAllBlocks,
+ isDisabled: true
+ };
+
+ deleteAllBlocksAction = {
+ labelKey: 'clipboard_labelForRemoveAllEntries',
+ labelTokens: [],
+ icon: 'trash',
+ method: requestDeleteAllBlocks,
+ isDisabled: true
+ };
+
+ var propertyActions = [
+ copyAllBlocksAction,
+ deleteAllBlocksAction
+ ];
+
+ if (vm.umbProperty) {
+ vm.umbProperty.setPropertyActions(propertyActions);
+ }
+
+ // Create Model Object, to manage our data for this Block Editor.
+ modelObject = blockEditorService.createModelObject(vm.model.value, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope);
+
+ $q.all([modelObject.load(), assetsService.loadJs('lib/sortablejs/Sortable.min.js', $scope)]).then(onLoaded);
+
+ };
+
+ // Called when we save the value, the server may return an updated data and our value is re-synced
+ // we need to deal with that here so that our model values are all in sync so we basically re-initialize.
+ function onServerValueChanged(newVal, oldVal) {
+
+ // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated.
+ if (typeof newVal !== 'object' || newVal === null) {// testing if we have null or undefined value or if the value is set to another type than Object.
+ vm.model.value = newVal = {};
+ }
+
+ modelObject.update(vm.model.value, $scope);
+ onLoaded();
+ }
+
+
+
+ function onLoaded() {
+
+ // Store a reference to the layout model, because we need to maintain this model.
+ vm.layout = modelObject.getLayout([]);
+
+
+ initializeLayout(vm.layout);
+
+ vm.availableContentTypesAliases = modelObject.getAvailableAliasesForBlockContent();
+ vm.availableBlockTypes = modelObject.getAvailableBlocksForBlockPicker();
+
+ updateClipboard(true);
+
+ vm.loading = false;
+
+ window.requestAnimationFrame(() => {
+ shadowRoot = $element[0].querySelector('umb-block-grid-root').shadowRoot;
+ })
+
+ }
+
+
+
+ function initializeLayout(layoutList, parentBlock, areaKey) {
+
+ // reference the invalid items of this list, to be removed after the loop.
+ var invalidLayoutItems = [];
+
+ // Append the blockObjects to our layout.
+ layoutList.forEach(layoutEntry => {
+
+ var block = initializeLayoutEntry(layoutEntry, parentBlock, areaKey);
+ if(!block) {
+ // then we need to filter this out and also update the underlying model. This could happen if the data is invalid.
+ invalidLayoutItems.push(layoutEntry);
+ }
+ });
+
+ // remove the ones that are invalid
+ invalidLayoutItems.forEach(entry => {
+ var index = layoutList.findIndex(x => x === entry);
+ if (index >= 0) {
+ layoutList.splice(index, 1);
+ }
+ });
+ }
+
+ function initializeLayoutEntry(layoutEntry, parentBlock, areaKey) {
+
+ // $block must have the data property to be a valid BlockObject, if not, its considered as a destroyed blockObject.
+ if (!layoutEntry.$block || layoutEntry.$block.data === undefined) {
+
+ // each layoutEntry should have a child array,
+ layoutEntry.areas = layoutEntry.areas || [];
+
+ var block = getBlockObject(layoutEntry);
+
+ // If this entry was not supported by our property-editor it would return 'null'.
+ if (block !== null) {
+ layoutEntry.$block = block;
+ } else {
+ return null;
+ }
+
+ // Create areas that is not already created:
+ block.config.areas?.forEach(areaConfig => {
+ const areaIndex = layoutEntry.areas.findIndex(x => x.key === areaConfig.key);
+ if(areaIndex === -1) {
+ layoutEntry.areas.push({
+ $config: areaConfig,
+ key: areaConfig.key,
+ items: []
+ })
+ } else {
+ // set $config as its not persisted:
+ layoutEntry.areas[areaIndex].$config = areaConfig;
+ initializeLayout(layoutEntry.areas[areaIndex].items, block, areaConfig.key);
+ }
+ });
+
+ // Clean up areas that does not exist in config:
+ let i = layoutEntry.areas.length;
+ while(i--) {
+ const layoutEntryArea = layoutEntry.areas[i];
+ const areaConfigIndex = block.config.areas.findIndex(x => x.key === layoutEntryArea.key);
+ if(areaConfigIndex === -1) {
+ layoutEntry.areas.splice(i, 1);
+ }
+ }
+
+ // if no columnSpan, then we set one:
+ if (!layoutEntry.columnSpan) {
+
+ const contextColumns = getContextColumns(parentBlock, areaKey)
+
+ if (block.config.columnSpanOptions.length > 0) {
+ // set columnSpan to minimum allowed span for this BlockType:
+ const minimumColumnSpan = block.config.columnSpanOptions.reduce((prev, option) => Math.min(prev, option.columnSpan), vm.gridColumns);
+
+ // If minimumColumnSpan is larger than contextColumns, then we will make it fit within context anyway:
+ layoutEntry.columnSpan = Math.min(minimumColumnSpan, contextColumns)
+ } else {
+ layoutEntry.columnSpan = contextColumns;
+ }
+ }
+ // if no rowSpan, then we set one:
+ if (!layoutEntry.rowSpan) {
+ layoutEntry.rowSpan = 1;
+ }
+
+
+ } else {
+ updateBlockObject(layoutEntry.$block);
+ }
+
+ return layoutEntry.$block;
+ }
+
+ vm.getContextColumns = getContextColumns;
+ function getContextColumns(parentBlock, areaKey) {
+
+ if(parentBlock != null) {
+ var area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if(!area) {
+ return null;
+ }
+
+ return area.$config.columnSpan;
+ }
+
+ return vm.gridColumns;
+ }
+
+ vm.getBlockGroupName = getBlockGroupName;
+ function getBlockGroupName(groupKey) {
+ return vm.blockGroups.find(x => x.key === groupKey)?.name;
+ }
+
+
+ function updateAllBlockObjects() {
+ // Update the blockObjects in our layout.
+ vm.layout.forEach(entry => {
+ // $block must have the data property to be a valid BlockObject, if not its considered as a destroyed blockObject.
+ if (entry.$block) {
+ updateBlockObject(entry.$block);
+ }
+ });
+ }
+
+ function applyDefaultViewForBlock(block) {
+
+ var defaultViewFolderPath = "views/propertyeditors/blockgrid/blockgridentryeditors/";
+
+ if (block.config.unsupported === true) {
+ block.view = defaultViewFolderPath + "unsupportedblock/unsupportedblock.editor.html";
+ } else {
+ block.view = defaultViewFolderPath + "gridblock/gridblock.editor.html";
+ }
+
+ }
+
+ /**
+ * Ensure that the containing content variant language and current property culture is transferred along
+ * to the scaffolded content object representing this block.
+ * This is required for validation along with ensuring that the umb-property inheritance is constantly maintained.
+ * @param {any} content
+ */
+ function ensureCultureData(content) {
+
+ if (!content) return;
+
+ if (vm.umbVariantContent.editor.content.language) {
+ // set the scaffolded content's language to the language of the current editor
+ content.language = vm.umbVariantContent.editor.content.language;
+ }
+ // currently we only ever deal with invariant content for blocks so there's only one
+ content.variants[0].tabs.forEach(tab => {
+ tab.properties.forEach(prop => {
+ // set the scaffolded property to the culture of the containing property
+ prop.culture = vm.umbProperty.property.culture;
+ });
+ });
+ }
+
+ function getBlockObject(entry) {
+ var block = modelObject.getBlockObject(entry);
+
+ if (block === null) return null;
+
+ if (!block.config.view) {
+ applyDefaultViewForBlock(block);
+ } else {
+ block.view = block.config.view;
+ }
+
+ block.stylesheet = block.config.stylesheet;
+ block.showValidation = true;
+
+ block.hideContentInOverlay = block.config.forceHideContentEditorInOverlay === true;
+ block.showContent = !block.hideContentInOverlay && block.content?.variants[0].tabs[0]?.properties.length > 0;
+ block.showSettings = block.config.settingsElementTypeKey != null;
+
+ // If we have content, otherwise it doesn't make sense to copy.
+ block.showCopy = vm.supportCopy && block.config.contentElementTypeKey != null;
+
+ block.blockUiVisibility = false;
+ block.showBlockUI = function () {
+ delete block.__timeout;
+ shadowRoot.querySelector('*[data-element-udi="'+block.layout.contentUdi+'"] .umb-block-grid__block > .umb-block-grid__block--context').scrollIntoView({block: "nearest", inline: "nearest", behavior: "smooth"});
+ block.blockUiVisibility = true;
+ };
+ block.onMouseLeave = function () {
+ block.__timeout = $timeout(() => {block.blockUiVisibility = false}, 200);
+ };
+ block.onMouseEnter = function () {
+ if (block.__timeout) {
+ $timeout.cancel(block.__timeout);
+ delete block.__timeout;
+ }
+ };
+
+
+ // Index is set by umbblockgridblock component and kept up to date by it.
+ block.index = 0;
+ block.setParentForm = function (parentForm) {
+ this._parentForm = parentForm;
+ };
+
+ /** decorator methods, to enable switching out methods without loosing references that would have been made in Block Views codes */
+ block.activate = function() {
+ this._activate();
+ };
+ block.edit = function() {
+ this._edit();
+ };
+ block.editSettings = function() {
+ this._editSettings();
+ };
+ block.requestDelete = function() {
+ this._requestDelete();
+ };
+ block.delete = function() {
+ this._delete();
+ };
+ block.copy = function() {
+ this._copy();
+ };
+ updateBlockObject(block);
+
+ return block;
+ }
+
+ /** As the block object now contains references to this instance of a property editor, we need to ensure that the Block Object contains latest references.
+ * This is a bit hacky but the only way to maintain this reference currently.
+ * Notice this is most relevant for invariant properties on variant documents, specially for the scenario where the scope of the reference we stored is destroyed, therefor we need to ensure we always have references to a current running property editor*/
+ function updateBlockObject(block) {
+
+ ensureCultureData(block.content);
+ ensureCultureData(block.settings);
+
+ block._activate = activateBlock.bind(null, block);
+ block._edit = function () {
+ var blockIndex = vm.layout.indexOf(this.layout);
+ editBlock(this, false, blockIndex, this._parentForm);
+ };
+ block._editSettings = function () {
+ var blockIndex = vm.layout.indexOf(this.layout);
+ editBlock(this, true, blockIndex, this._parentForm);
+ };
+ block._requestDelete = requestDeleteBlock.bind(null, block);
+ block._delete = deleteBlock.bind(null, block);
+ block._copy = copyBlock.bind(null, block);
+ }
+
+ function addNewBlock(parentBlock, areaKey, index, contentElementTypeKey, options) {
+
+ // Create layout entry. (not added to property model jet.)
+ const layoutEntry = modelObject.create(contentElementTypeKey);
+ if (layoutEntry === null) {
+ return false;
+ }
+
+ // Development note: Notice this is ran before added to the data model.
+ initializeLayoutEntry(layoutEntry, parentBlock, areaKey);
+
+ // make block model
+ const blockObject = layoutEntry.$block;
+ if (blockObject === null) {
+ return false;
+ }
+
+ const area = parentBlock?.layout.areas.find(x => x.key === areaKey);
+
+ // fit in row?
+ if (options.fitInRow === true) {
+ /*
+ Idea for finding the proper size for this new block:
+ Use clientRect to measure previous items, once one is more to the right than the one before, then it must be a new line.
+ Combine those from the line to inspect if there is left room. Se if the left room fits?
+ Additionally the sizingOptions of the other can come into play?
+ */
+
+ if(blockObject.config.columnSpanOptions.length > 0) {
+ const minColumnSpan = blockObject.config.columnSpanOptions.reduce((prev, option) => Math.min(prev, option.columnSpan), vm.gridColumns);
+ layoutEntry.columnSpan = minColumnSpan;
+ } else {
+ // because no columnSpanOptions defined, then use contextual layout columns.
+ layoutEntry.columnSpan = area? area.$config.columnSpan : vm.gridColumns;
+ }
+
+ } else {
+
+ if(blockObject.config.columnSpanOptions.length > 0) {
+ // set columnSpan to maximum allowed span for this BlockType:
+ const maximumColumnSpan = blockObject.config.columnSpanOptions.reduce((prev, option) => Math.max(prev, option.columnSpan), 1);
+ layoutEntry.columnSpan = maximumColumnSpan;
+ } else {
+ // because no columnSpanOptions defined, then use contextual layout columns.
+ layoutEntry.columnSpan = area? area.$config.columnSpan : vm.gridColumns;
+ }
+
+ }
+
+ // add layout entry at the decided location in layout.
+ if(parentBlock != null) {
+
+ if(!area) {
+ console.error("Could not find area in block creation");
+ }
+
+ // limit columnSpan by areaConfig columnSpan:
+ layoutEntry.columnSpan = Math.min(layoutEntry.columnSpan, area.$config.columnSpan);
+
+ area.items.splice(index, 0, layoutEntry);
+ } else {
+
+ // limit columnSpan by grid columnSpan:
+ layoutEntry.columnSpan = Math.min(layoutEntry.columnSpan, vm.gridColumns);
+
+ vm.layout.splice(index, 0, layoutEntry);
+ }
+
+ // lets move focus to this new block.
+ vm.setBlockFocus(blockObject);
+
+ return true;
+ }
+
+ function getLayoutEntryByContentID(layoutList, contentUdi) {
+ for(const entry of layoutList) {
+ if(entry.contentUdi === contentUdi) {
+ return {entry: entry, layoutList: layoutList};
+ }
+ for(const area of entry.areas) {
+ const result = getLayoutEntryByContentID(area.items, contentUdi);
+ if(result !== null) {
+ return result;
+ }
+ }
+ }
+ return null;
+ }
+
+ // Used by umbblockgridentries.component to check for drag n' drop allowance:
+ vm.isElementTypeKeyAllowedAt = isElementTypeKeyAllowedAt;
+ function isElementTypeKeyAllowedAt(parentBlock, areaKey, contentElementTypeKey) {
+ return getAllowedTypesOf(parentBlock, areaKey).filter(x => x.blockConfigModel.contentElementTypeKey === contentElementTypeKey).length > 0;
+ }
+
+ // Used by umbblockgridentries.component to set data for a block when drag n' drop specials(force new line etc.):
+ vm.getLayoutEntryByIndex = getLayoutEntryByIndex;
+ function getLayoutEntryByIndex(parentBlock, areaKey, index) {
+ if(parentBlock) {
+ const area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if(area && area.items.length >= index) {
+ return area.items[index];
+ }
+ } else {
+ return vm.layout[index];
+ }
+ return null;
+ }
+
+
+ // Used by umbblockgridentries.component to check how many block types that are available for creation in an area:
+ vm.getAllowedTypesOf = getAllowedTypesOf;
+ function getAllowedTypesOf(parentBlock, areaKey) {
+
+ if(areaKey == null || parentBlock == null) {
+ return vm.availableBlockTypes.filter(x => x.blockConfigModel.allowAtRoot);
+ }
+
+ const area = parentBlock.layout.areas.find(x => x.key === areaKey);
+
+ if(area) {
+ if(area.$config.specifiedAllowance.length > 0) {
+
+ const allowedElementTypes = [];
+
+ // Then add specific types (This allows to overwrite the amount for a specific type)
+ area.$config.specifiedAllowance?.forEach(allowance => {
+ if(allowance.groupKey) {
+ vm.availableBlockTypes.forEach(blockType => {
+ if(blockType.blockConfigModel.groupKey === allowance.groupKey && blockType.blockConfigModel.allowInAreas === true) {
+ if(allowedElementTypes.indexOf(blockType) === -1) {
+ allowedElementTypes.push(blockType);
+ }
+ }
+ });
+ } else
+ if(allowance.elementTypeKey) {
+ const blockType = vm.availableBlockTypes.find(x => x.blockConfigModel.contentElementTypeKey === allowance.elementTypeKey);
+ if(allowedElementTypes.indexOf(blockType) === -1) {
+ allowedElementTypes.push(blockType);
+ }
+ }
+ });
+
+ return allowedElementTypes;
+ } else {
+ // as none specifiedAllowance was defined we will allow all area Blocks:
+ return vm.availableBlockTypes.filter(x => x.blockConfigModel.allowInAreas === true);
+ }
+ }
+ return vm.availableBlockTypes;
+ }
+
+ function deleteBlock(block) {
+
+ const result = getLayoutEntryByContentID(vm.layout, block.layout.contentUdi);
+ if (result === null) {
+ console.error("Could not find layout entry of block with udi: "+block.layout.contentUdi);
+ return;
+ }
+
+ setDirty();
+
+ result.entry.areas.forEach(area => {
+ area.items.forEach(areaEntry => {
+ deleteBlock(areaEntry.$block);
+ });
+ });
+
+ const layoutListIndex = result.layoutList.indexOf(result.entry);
+ var removed = result.layoutList.splice(layoutListIndex, 1);
+ removed.forEach(x => {
+ // remove any server validation errors associated
+ var guids = [udiService.getKey(x.contentUdi), (x.settingsUdi ? udiService.getKey(x.settingsUdi) : null)];
+ guids.forEach(guid => {
+ if (guid) {
+ serverValidationManager.removePropertyError(guid, vm.umbProperty.property.culture, vm.umbProperty.property.segment, "", { matchType: "contains" });
+ }
+ })
+ });
+
+ modelObject.removeDataAndDestroyModel(block);
+ }
+
+ function deleteAllBlocks() {
+ while(vm.layout.length) {
+ deleteBlock(vm.layout[0].$block);
+ };
+ }
+
+ function activateBlock(blockObject) {
+ blockObject.active = true;
+ }
+
+ function editBlock(blockObject, openSettings, blockIndex, parentForm, options) {
+
+ options = options || vm.options;
+
+ /*
+ We cannot use the blockIndex as is not possibility in grid, cause of the polymorphism.
+ But we keep it to stay consistent with Block List Editor.
+ if (blockIndex === undefined) {
+ throw "blockIndex was not specified on call to editBlock";
+ }
+ */
+
+ var wasNotActiveBefore = blockObject.active !== true;
+
+ // don't open the editor overlay if block has hidden its content editor in overlays and we are requesting to open content, not settings.
+ if (openSettings !== true && blockObject.hideContentInOverlay === true) {
+ return;
+ }
+
+ // if requesting to open settings but we don't have settings then return.
+ if (openSettings === true && !blockObject.config.settingsElementTypeKey) {
+ return;
+ }
+
+ activateBlock(blockObject);
+
+ // make a clone to avoid editing model directly.
+ var blockContentClone = Utilities.copy(blockObject.content);
+ var blockSettingsClone = null;
+
+ if (blockObject.config.settingsElementTypeKey) {
+ blockSettingsClone = Utilities.copy(blockObject.settings);
+ }
+
+ var blockEditorModel = {
+ $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing
+ $parentForm: parentForm || vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form)
+ hideContent: blockObject.hideContentInOverlay,
+ openSettings: openSettings === true,
+ createFlow: options.createFlow === true,
+ liveEditing: liveEditing,
+ title: blockObject.label,
+ view: "views/common/infiniteeditors/blockeditor/blockeditor.html",
+ size: blockObject.config.editorSize || "medium",
+ hideSubmitButton: vm.readonly,
+ submit: function(blockEditorModel) {
+
+ if (liveEditing === false) {
+ // transfer values when submitting in none-liveediting mode.
+ blockObject.retrieveValuesFrom(blockEditorModel.content, blockEditorModel.settings);
+ }
+
+ blockObject.active = false;
+ editorService.close();
+ },
+ close: function(blockEditorModel) {
+ if (blockEditorModel.createFlow) {
+ deleteBlock(blockObject);
+ } else {
+ if (liveEditing === true) {
+ // revert values when closing in liveediting mode.
+ blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone);
+ }
+ if (wasNotActiveBefore === true) {
+ blockObject.active = false;
+ }
+ }
+ editorService.close();
+ }
+ };
+
+ if (liveEditing === true) {
+ blockEditorModel.content = blockObject.content;
+ blockEditorModel.settings = blockObject.settings;
+ } else {
+ blockEditorModel.content = blockContentClone;
+ blockEditorModel.settings = blockSettingsClone;
+ }
+
+ // open property settings editor
+ editorService.open(blockEditorModel);
+ }
+
+ vm.requestShowCreate = requestShowCreate;
+ function requestShowCreate(parentBlock, areaKey, createIndex, mouseEvent, options) {
+
+ if (vm.blockTypePickerIsOpen === true) {
+ return;
+ }
+
+ options = options || {};
+
+ const availableTypes = getAllowedTypesOf(parentBlock, areaKey);
+
+ if (availableTypes.length === 1) {
+ var wasAdded = false;
+ var blockType = availableTypes[0];
+
+ wasAdded = addNewBlock(parentBlock, areaKey, createIndex, blockType.blockConfigModel.contentElementTypeKey, options);
+
+ if(wasAdded && !(mouseEvent.ctrlKey || mouseEvent.metaKey)) {
+ userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex);
+ }
+ } else {
+ showCreateDialog(parentBlock, areaKey, createIndex, false, options);
+ }
+
+ }
+ vm.requestShowClipboard = requestShowClipboard;
+ function requestShowClipboard(parentBlock, areaKey, createIndex, mouseEvent) {
+ showCreateDialog(parentBlock, areaKey, createIndex, true);
+ }
+
+ vm.showCreateDialog = showCreateDialog;
+ function showCreateDialog(parentBlock, areaKey, createIndex, openClipboard, options) {
+
+ if (vm.blockTypePickerIsOpen === true) {
+ return;
+ }
+
+
+ options = options || {};
+
+ const availableTypes = getAllowedTypesOf(parentBlock, areaKey);
+
+ if (availableTypes.length === 0) {
+ return;
+ }
+
+ const availableBlockGroups = vm.blockGroups.filter(group => !!availableTypes.find(item => item.blockConfigModel.groupKey === group.key));
+
+ var amountOfAvailableTypes = availableTypes.length;
+ var availableContentTypesAliases = modelObject.getAvailableAliasesOfElementTypeKeys(availableTypes.map(x => x.blockConfigModel.contentElementTypeKey));
+ var availableClipboardItems = vm.clipboardItems.filter(
+ (entry) => {
+ if(entry.aliases) {
+ return entry.aliases.filter((alias, index) => availableContentTypesAliases.indexOf(alias) === index);
+ } else {
+ return availableContentTypesAliases.indexOf(entry.alias) !== -1;
+ }
+ }
+ );
+
+ var createLabel;
+ if(parentBlock) {
+ const area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ createLabel = area.$config.createLabel;
+ } else {
+ createLabel = vm.createLabel;
+ }
+ const headline = createLabel || (amountOfAvailableTypes.length === 1 ? localizationService.tokenReplace(vm.labels.blockEditor_addThis, [availableTypes[0].elementTypeModel.name]) : vm.labels.grid_addElement);
+
+ var blockPickerModel = {
+ $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing
+ $parentForm: vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form)
+ availableItems: availableTypes,
+ blockGroups: availableBlockGroups,
+ title: headline,
+ openClipboard: openClipboard,
+ orderBy: "$index",
+ view: "views/common/infiniteeditors/blockpicker/blockpicker.html",
+ size: (amountOfAvailableTypes > 8 ? "medium" : "small"),
+ filter: (amountOfAvailableTypes > 8),
+ clickPasteItem: function(item, mouseEvent) {
+ if (Array.isArray(item.pasteData)) {
+ var indexIncrementor = 0;
+ item.pasteData.forEach(function (entry) {
+ if (requestPasteFromClipboard(parentBlock, areaKey, createIndex + indexIncrementor, entry, item.type)) {
+ indexIncrementor++;
+ }
+ });
+ } else {
+ requestPasteFromClipboard(parentBlock, areaKey, createIndex, item.pasteData, item.type);
+ }
+ if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) {
+ blockPickerModel.close();
+ }
+ },
+ submit: function(blockPickerModel, mouseEvent) {
+ var wasAdded = false;
+ if (blockPickerModel && blockPickerModel.selectedItem) {
+ wasAdded = addNewBlock(parentBlock, areaKey, createIndex, blockPickerModel.selectedItem.blockConfigModel.contentElementTypeKey, options);
+ }
+
+ if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) {
+ blockPickerModel.close();
+ if (wasAdded) {
+ userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex);
+ }
+ }
+ },
+ close: function() {
+ // if opened by a inline creator button(index less than length), we want to move the focus away, to hide line-creator.
+
+ // add layout entry at the decided location in layout.
+ if(parentBlock != null) {
+ var area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if(!area) {
+ console.error("Could not find area in block creation close flow");
+ }
+ if (createIndex < area.items.length) {
+ const blockOfInterest = area.items[Math.max(createIndex-1, 0)].$block;
+ vm.setBlockFocus(blockOfInterest);
+ }
+ } else {
+ if (createIndex < vm.layout.length) {
+ const blockOfInterest = vm.layout[Math.max(createIndex-1, 0)].$block;
+ vm.setBlockFocus(blockOfInterest);
+ }
+ }
+
+
+ editorService.close();
+ vm.blockTypePickerIsOpen = false;
+ }
+ };
+
+ blockPickerModel.clickClearClipboard = function ($event) {
+ clipboardService.clearEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, availableContentTypesAliases);
+ clipboardService.clearEntriesOfType(clipboardService.TYPES.BLOCK, availableContentTypesAliases);
+ };
+
+ blockPickerModel.clipboardItems = availableClipboardItems;
+
+ vm.blockTypePickerIsOpen = true;
+ // open block picker overlay
+ editorService.open(blockPickerModel);
+
+ };
+ function userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex) {
+ var blockObject;
+
+ if (parentBlock) {
+ var area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if (!area) {
+ console.error("Area could not be found...", parentBlock, areaKey)
+ }
+ blockObject = area.items[createIndex].$block;
+ } else {
+ if (vm.layout.length <= createIndex) {
+ console.error("Create index does not fit within available items of root.")
+ }
+ blockObject = vm.layout[createIndex].$block;
+ }
+ // edit block if not `hideContentInOverlay` and there is content properties.
+ if(blockObject.hideContentInOverlay !== true && blockObject.content.variants[0].tabs[0]?.properties.length > 0) {
+ vm.options.createFlow = true;
+ blockObject.edit();
+ vm.options.createFlow = false;
+ }
+ }
+
+ function updateClipboard(firstTime) {
+
+ var oldAmount = vm.clipboardItems.length;
+
+ vm.clipboardItems = [];
+
+ var entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases);
+ entriesForPaste.forEach(function (entry) {
+ var pasteEntry = {
+ type: clipboardService.TYPES.ELEMENT_TYPE,
+ date: entry.date,
+ alias: entry.alias,
+ pasteData: entry.data,
+ elementTypeModel: {
+ name: entry.label,
+ icon: entry.icon
+ }
+ }
+ if(Array.isArray(entry.data) === false) {
+ var scaffold = modelObject.getScaffoldFromAlias(entry.alias);
+ if(scaffold) {
+ pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(scaffold.contentTypeKey);
+ }
+ }
+ vm.clipboardItems.push(pasteEntry);
+ });
+
+ var entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.BLOCK, vm.availableContentTypesAliases);
+ entriesForPaste.forEach(function (entry) {
+ var pasteEntry = {
+ type: clipboardService.TYPES.BLOCK,
+ date: entry.date,
+ alias: entry.alias,
+ aliases: entry.aliases,
+ pasteData: entry.data,
+ elementTypeModel: {
+ name: entry.label,
+ icon: entry.icon
+ }
+ }
+ if(Array.isArray(entry.data) === false) {
+ pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(entry.data.data.contentTypeKey);
+ }
+ vm.clipboardItems.push(pasteEntry);
+ });
+
+ vm.clipboardItems.sort( (a, b) => {
+ return b.date - a.date
+ });
+
+ if(firstTime !== true && vm.clipboardItems.length > oldAmount) {
+ jumpClipboard();
+ }
+ }
+
+ var jumpClipboardTimeout;
+ function jumpClipboard() {
+
+ if(jumpClipboardTimeout) {
+ return;
+ }
+
+ vm.jumpClipboardButton = true;
+ jumpClipboardTimeout = $timeout(() => {
+ vm.jumpClipboardButton = false;
+ jumpClipboardTimeout = null;
+ }, 2000);
+ }
+
+ function requestCopyAllBlocks() {
+
+ var aliases = [];
+
+ var elementTypesToCopy = vm.layout.filter(entry => entry.$block.config.unsupported !== true).map(
+ (entry) => {
+
+ aliases.push(entry.$block.content.contentTypeAlias);
+
+ const clipboardData = { "layout": entry.$block.layout, "data": entry.$block.data, "settingsData": entry.$block.settingsData };
+ // If areas:
+ if(entry.$block.layout.areas.length > 0) {
+ clipboardData.nested = gatherNestedBlocks(entry.$block);
+ }
+ // No need to clone the data as its begin handled by the clipboardService.
+ return clipboardData;
+ }
+ );
+
+ // remove duplicate aliases
+ aliases = aliases.filter((item, index) => aliases.indexOf(item) === index);
+
+ var contentNodeName = "?";
+ var contentNodeIcon = null;
+ if (vm.umbVariantContent) {
+ contentNodeName = vm.umbVariantContent.editor.content.name;
+ if (vm.umbVariantContentEditors) {
+ contentNodeIcon = vm.umbVariantContentEditors.content.icon.split(" ")[0];
+ } else if (vm.umbElementEditorContent) {
+ contentNodeIcon = vm.umbElementEditorContent.model.documentType.icon.split(" ")[0];
+ }
+ } else if (vm.umbElementEditorContent) {
+ contentNodeName = vm.umbElementEditorContent.model.documentType.name;
+ contentNodeIcon = vm.umbElementEditorContent.model.documentType.icon.split(" ")[0];
+ }
+
+ localizationService.localize("clipboard_labelForArrayOfItemsFrom", [vm.model.label, contentNodeName]).then(function (localizedLabel) {
+ clipboardService.copyArray(clipboardService.TYPES.BLOCK, aliases, elementTypesToCopy, localizedLabel, contentNodeIcon || "icon-thumbnail-list", vm.model.id);
+ });
+ };
+
+ function gatherNestedBlocks(block) {
+ const nested = [];
+
+ block.layout.areas.forEach(area => {
+ area.items.forEach(item => {
+ const itemData = {"layout": item.$block.layout, "data": item.$block.data, "settingsData":item.$block.settingsData, "areaKey": area.key};
+ if(item.$block.layout.areas?.length > 0) {
+ itemData.nested = gatherNestedBlocks(item.$block);
+ }
+ nested.push(itemData);
+ });
+ });
+
+ return nested;
+ }
+ function copyBlock(block) {
+
+ const clipboardData = {"layout": block.layout, "data": block.data, "settingsData":block.settingsData};
+
+ // If areas:
+ if(block.layout.areas.length > 0) {
+ clipboardData.nested = gatherNestedBlocks(block);
+ }
+
+ clipboardService.copy(clipboardService.TYPES.BLOCK, block.content.contentTypeAlias, clipboardData, block.label, block.content.icon, block.content.udi);
+ }
+
+ function pasteClipboardEntry(parentBlock, areaKey, index, pasteEntry, pasteType) {
+
+ if (pasteEntry === undefined) {
+ return null;
+ }
+
+ if(!isElementTypeKeyAllowedAt(parentBlock, areaKey, pasteEntry.data.contentTypeKey)) {
+ console.error("paste clipboard entry inserted an disallowed type.")
+ return {failed: true};
+ }
+
+ var layoutEntry;
+ if (pasteType === clipboardService.TYPES.ELEMENT_TYPE) {
+ layoutEntry = modelObject.createFromElementType(pasteEntry);
+ } else if (pasteType === clipboardService.TYPES.BLOCK) {
+ layoutEntry = modelObject.createFromBlockData(pasteEntry);
+ } else {
+ // Not a supported paste type.
+ return null;
+ }
+
+ if (layoutEntry === null) {
+ // Pasting did not go well.
+ return null;
+ }
+
+ if (initializeLayoutEntry(layoutEntry, parentBlock, areaKey) === null) {
+ return null;
+ }
+
+ if (layoutEntry.$block === null) {
+ // Initialization of the Block Object didn't go well, therefor we will fail the paste action.
+ return null;
+ }
+
+ var nestedBlockFailed = false;
+ if(pasteEntry.nested && pasteEntry.nested.length) {
+
+ // Handle nested blocks:
+ pasteEntry.nested.forEach( nestedEntry => {
+ if(nestedEntry.areaKey) {
+ const data = pasteClipboardEntry(layoutEntry.$block, nestedEntry.areaKey, null, nestedEntry, pasteType);
+ if(data === null || data.failed === true) {
+ nestedBlockFailed = true;
+ }
+ }
+ });
+
+ }
+
+ // insert layout entry at the decided location in layout.
+ if(parentBlock != null) {
+ var area = parentBlock.layout.areas.find(x => x.key === areaKey);
+ if (!area) {
+ console.error("Area could not be found...", parentBlock, areaKey)
+ }
+ if(index !== null) {
+ area.items.splice(index, 0, layoutEntry);
+ } else {
+ area.items.push(layoutEntry);
+ }
+ } else {
+ if(index !== null) {
+ vm.layout.splice(index, 0, layoutEntry);
+ } else {
+ vm.layout.push(layoutEntry);
+ }
+ }
+
+ return {layoutEntry, failed: nestedBlockFailed};
+ }
+
+ function requestPasteFromClipboard(parentBlock, areaKey, index, pasteEntry, pasteType) {
+
+ const data = pasteClipboardEntry(parentBlock, areaKey, index, pasteEntry, pasteType);
+ if(data) {
+ if(data.failed === true) {
+ // one or more of nested block creation failed.
+ // Ask wether the user likes to continue:
+ if(data.layoutEntry) {
+ var blockToRevert = data.layoutEntry.$block;
+ localizationService.localizeMany(["blockEditor_confirmPasteDisallowedNestedBlockHeadline", "blockEditor_confirmPasteDisallowedNestedBlockMessage", "general_revert", "general_continue"]).then(function (localizations) {
+ const overlay = {
+ title: localizations[0],
+ content: localizationService.tokenReplace(localizations[1], [blockToRevert.label]),
+ disableBackdropClick: true,
+ closeButtonLabel: localizations[2],
+ submitButtonLabel: localizations[3],
+ close: function () {
+ // revert:
+ deleteBlock(blockToRevert);
+ overlayService.close();
+ },
+ submit: function () {
+ // continue:
+ overlayService.close();
+ }
+ };
+
+ overlayService.open(overlay);
+ });
+ } else {
+ console.error("Pasting failed, there was nothing to revert. Should be good to move on with content creation.")
+ }
+ } else {
+ vm.currentBlockInFocus = data.layoutEntry.$block;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function requestDeleteBlock(block) {
+ if (vm.readonly) return;
+
+ localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockMessage", "contentTypeEditor_yesDelete"]).then(function (data) {
+ const overlay = {
+ title: data[0],
+ content: localizationService.tokenReplace(data[1], [block.label]),
+ submitButtonLabel: data[2],
+ close: function () {
+ overlayService.close();
+ },
+ submit: function () {
+ deleteBlock(block);
+ overlayService.close();
+ }
+ };
+
+ overlayService.confirmDelete(overlay);
+ });
+ }
+
+ function requestDeleteAllBlocks() {
+ localizationService.localizeMany(["content_nestedContentDeleteAllItems", "general_delete"]).then(function (data) {
+ overlayService.confirmDelete({
+ title: data[1],
+ content: data[0],
+ close: function () {
+ overlayService.close();
+ },
+ submit: function () {
+ deleteAllBlocks();
+ overlayService.close();
+ }
+ });
+ });
+ }
+
+ function openSettingsForBlock(block, blockIndex, parentForm) {
+ editBlock(block, true, blockIndex, parentForm);
+ }
+
+ vm.blockEditorApi = {
+ activateBlock: activateBlock,
+ editBlock: editBlock,
+ copyBlock: copyBlock,
+ requestDeleteBlock: requestDeleteBlock,
+ deleteBlock: deleteBlock,
+ openSettingsForBlock: openSettingsForBlock,
+ requestShowCreate: requestShowCreate,
+ requestShowClipboard: requestShowClipboard,
+ internal: vm,
+ readonly: vm.readonly
+ };
+
+ vm.setDirty = setDirty;
+ function setDirty() {
+ if (vm.propertyForm) {
+ vm.propertyForm.$setDirty();
+ }
+ }
+
+ function onAmountOfBlocksChanged() {
+
+ // enable/disable property actions
+ if (copyAllBlocksAction) {
+ copyAllBlocksAction.isDisabled = vm.layout.length === 0;
+ }
+ if (deleteAllBlocksAction) {
+ deleteAllBlocksAction.isDisabled = vm.layout.length === 0;
+ }
+
+ // validate limits:
+ if (vm.propertyForm && vm.validationLimit) {
+
+ var isMinRequirementGood = vm.validationLimit.min === null || vm.layout.length >= vm.validationLimit.min;
+ vm.propertyForm.minCount.$setValidity("minCount", isMinRequirementGood);
+
+ var isMaxRequirementGood = vm.validationLimit.max === null || vm.layout.length <= vm.validationLimit.max;
+ vm.propertyForm.maxCount.$setValidity("maxCount", isMaxRequirementGood);
+ }
+ }
+
+ unsubscribe.push($scope.$watch(() => vm.layout.length, onAmountOfBlocksChanged));
+
+ $scope.$on("$destroy", function () {
+ for (const subscription of unsubscribe) {
+ subscription();
+ }
+ });
+ }
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js
new file mode 100644
index 0000000000..2c4a4bb262
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js
@@ -0,0 +1,83 @@
+(function () {
+ "use strict";
+
+ /**
+ * @ngdoc directive
+ * @name umbraco.directives.directive:umbBlockGridBlock
+ * @description
+ * The component to render the view for a block in the Block Grid Editor.
+ * If a stylesheet is used then this uses a ShadowDom to make a scoped element.
+ * This way the backoffice styling does not collide with the block style.
+ */
+
+ angular
+ .module("umbraco")
+ .component("umbBlockGridBlock", {
+ controller: BlockGridBlockController,
+ controllerAs: "model",
+ bindings: {
+ stylesheet: "@",
+ view: "@",
+ block: "=",
+ api: "<",
+ index: "<",
+ parentForm: "<"
+ },
+ require: {
+ valFormManager: "^^valFormManager"
+ }
+ }
+ );
+
+ function BlockGridBlockController($scope, $compile, $element) {
+ var model = this;
+
+ model.$onInit = function () {
+
+ // let the Block know about its form
+ model.block.setParentForm(model.parentForm);
+
+ // let the Block know about the current index
+ model.block.index = model.index;
+
+ $scope.block = model.block;
+ $scope.api = model.api;
+ $scope.index = model.index;
+ $scope.parentForm = model.parentForm;
+ $scope.valFormManager = model.valFormManager;
+
+ var shadowRoot = $element[0].attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML =
+ `
+ ${ model.stylesheet ? `
+ `
+ : ''
+ }
+
+ `;
+ $compile(shadowRoot)($scope);
+
+ };
+
+
+ // We need to watch for changes on primitive types and update the $scope values.
+ model.$onChanges = function (changes) {
+ if (changes.index) {
+ var index = changes.index.currentValue;
+ $scope.index = index;
+
+ // let the Block know about the current index:
+ model.block.index = index;
+ model.block.updateLabel();
+ }
+ };
+
+ }
+
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js
new file mode 100644
index 0000000000..c105b98fcb
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js
@@ -0,0 +1,658 @@
+(function () {
+ "use strict";
+
+
+
+ function getInterpolatedIndexOfPositionInWeightMap(target, weights) {
+ const map = [0];
+ weights.reduce((a, b, i) => { return map[i+1] = a+b; }, 0);
+ const foundValue = map.reduce((a, b) => {
+ let aDiff = Math.abs(a - target);
+ let bDiff = Math.abs(b - target);
+
+ if (aDiff === bDiff) {
+ return a < b ? a : b;
+ } else {
+ return bDiff < aDiff ? b : a;
+ }
+ })
+ const foundIndex = map.indexOf(foundValue);
+ const targetDiff = (target-foundValue);
+ let interpolatedIndex = foundIndex;
+ if (targetDiff < 0 && foundIndex === 0) {
+ // Don't adjust.
+ } else if (targetDiff > 0 && foundIndex === map.length-1) {
+ // Don't adjust.
+ } else {
+ const foundInterpolationWeight = weights[targetDiff >= 0 ? foundIndex : foundIndex-1];
+ interpolatedIndex += foundInterpolationWeight === 0 ? interpolatedIndex : (targetDiff/foundInterpolationWeight)
+ }
+ return interpolatedIndex;
+ }
+
+
+ function isWithinRect(x, y, rect, modifier) {
+ return (x > rect.left - modifier && x < rect.right + modifier && y > rect.top - modifier && y < rect.bottom + modifier);
+ }
+
+
+
+ /**
+ * @ngdoc directive
+ * @name umbraco.directives.directive:umbBlockGridEntries
+ * @description
+ * renders all blocks for a given list for the block grid editor
+ */
+
+ angular
+ .module("umbraco")
+ .component("umbBlockGridEntries", {
+ templateUrl: 'views/propertyeditors/blockgrid/umb-block-grid-entries.html',
+ controller: BlockGridEntriesController,
+ controllerAs: "vm",
+ bindings: {
+ blockEditorApi: "<",
+ entries: "<",
+ layoutColumns: "<",
+ areaKey: "",
+ parentBlock: "",
+ parentForm: "",
+ propertyEditorForm: "",
+ depth: "@",
+ createLabel: "@?"
+ }
+ }
+ );
+
+ function BlockGridEntriesController($element, $scope, $timeout) {
+
+ const unsubscribe = [];
+ const vm = this;
+ vm.showNotAllowedUI = false;
+ vm.invalidAmount = false;
+ vm.areaConfig = null;
+ vm.locallyAvailableBlockTypes = 0;
+ vm.invalidBlockTypes = [];
+
+ vm.movingLayoutEntry = null;
+ vm.layoutColumnsInt = 0;
+
+ vm.$onInit = function () {
+ initializeSortable();
+
+ if(vm.parentBlock) {
+ vm.areaConfig = vm.parentBlock.config.areas.find(area => area.key === vm.areaKey);
+ }
+
+ vm.locallyAvailableBlockTypes = vm.blockEditorApi.internal.getAllowedTypesOf(vm.parentBlock, vm.areaKey);
+
+ unsubscribe.push($scope.$watch('vm.entries', onLocalAmountOfBlocksChanged, true));
+ };
+
+ unsubscribe.push($scope.$watch("layoutColumns", (newVal, oldVal) => {
+ vm.layoutColumnsInt = parseInt(vm.layoutColumns, 10);
+ }));
+
+
+ function onLocalAmountOfBlocksChanged() {
+
+ if (vm.entriesForm && vm.areaConfig) {
+
+ var isMinRequirementGood = vm.entries.length >= vm.areaConfig.minAllowed;
+ vm.entriesForm.areaMinCount.$setValidity("areaMinCount", isMinRequirementGood);
+
+ var isMaxRequirementGood = vm.areaConfig.maxAllowed == null || vm.entries.length <= vm.areaConfig.maxAllowed;
+ vm.entriesForm.areaMaxCount.$setValidity("areaMaxCount", isMaxRequirementGood);
+
+ vm.invalidBlockTypes = [];
+
+ vm.areaConfig.specifiedAllowance.forEach(allowance => {
+
+ const minAllowed = allowance.minAllowed || 0;
+ const maxAllowed = allowance.maxAllowed || 0;
+
+ // For block groups:
+ if(allowance.groupKey) {
+
+ const groupElementTypeKeys = vm.locallyAvailableBlockTypes.filter(blockType => blockType.blockConfigModel.groupKey === allowance.groupKey && blockType.blockConfigModel.allowInAreas === true).map(x => x.blockConfigModel.contentElementTypeKey);
+ const groupAmount = vm.entries.filter(entry => groupElementTypeKeys.indexOf(entry.$block.data.contentTypeKey) !== -1).length;
+
+ if(groupAmount < minAllowed || (maxAllowed > 0 && groupAmount > maxAllowed)) {
+ vm.invalidBlockTypes.push({
+ 'groupKey': allowance.groupKey,
+ 'name': vm.blockEditorApi.internal.getBlockGroupName(allowance.groupKey),
+ 'amount': groupAmount,
+ 'minRequirement': minAllowed,
+ 'maxRequirement': maxAllowed
+ });
+ }
+ } else
+ // For specific elementTypes:
+ if(allowance.elementTypeKey) {
+
+ const amount = vm.entries.filter(entry => entry.$block.data.contentTypeKey === allowance.elementTypeKey).length;
+
+ if(amount < minAllowed || (maxAllowed > 0 && amount > maxAllowed)) {
+ vm.invalidBlockTypes.push({
+ 'key': allowance.elementTypeKey,
+ 'name': vm.locallyAvailableBlockTypes.find(blockType => blockType.blockConfigModel.contentElementTypeKey === allowance.elementTypeKey).elementTypeModel.name,
+ 'amount': amount,
+ 'minRequirement': minAllowed,
+ 'maxRequirement': maxAllowed
+ });
+ }
+ }
+ });
+ var isTypeRequirementGood = vm.invalidBlockTypes.length === 0;
+ vm.entriesForm.areaTypeRequirements.$setValidity("areaTypeRequirements", isTypeRequirementGood);
+
+
+ vm.invalidAmount = !isMinRequirementGood || !isMaxRequirementGood || !isTypeRequirementGood;
+
+ $element.toggleClass("--invalid", vm.invalidAmount);
+ }
+ }
+
+ vm.acceptBlock = function(contentTypeKey) {
+ return vm.blockEditorApi.internal.isElementTypeKeyAllowedAt(vm.parentBlock, vm.areaKey, contentTypeKey);
+ }
+
+ vm.getLayoutEntryByIndex = function(index) {
+ return vm.blockEditorApi.internal.getLayoutEntryByIndex(vm.parentBlock, vm.areaKey, index);
+ }
+
+ vm.showNotAllowed = function() {
+ vm.showNotAllowedUI = true;
+ $scope.$evalAsync();
+ }
+ vm.hideNotAllowed = function() {
+ vm.showNotAllowedUI = false;
+ $scope.$evalAsync();
+ }
+
+ var revertIndicateDroppableTimeout;
+ vm.revertIndicateDroppable = function() {
+ revertIndicateDroppableTimeout = $timeout(() => {
+ vm.droppableIndication = false;
+ }, 2000);
+ }
+ vm.indicateDroppable = function() {
+ if (revertIndicateDroppableTimeout) {
+ $timeout.cancel(revertIndicateDroppableTimeout);
+ revertIndicateDroppableTimeout = null;
+ }
+ vm.droppableIndication = true;
+ $scope.$evalAsync();
+ }
+
+ function initializeSortable() {
+
+ const gridLayoutContainerEl = $element[0].querySelector('.umb-block-grid__layout-container');
+ var _lastIndicationContainerVM = null;
+
+ var targetRect = null;
+ var relatedEl = null;
+ var ghostEl = null;
+ var ghostRect = null;
+ var dragX = 0;
+ var dragY = 0;
+ var dragOffsetX = 0;
+
+ var ghostElIndicateForceLeft = null;
+ var ghostElIndicateForceRight = null;
+
+ var approvedContainerEl = null;
+
+ // Setup DOM method for communication between sortables:
+ gridLayoutContainerEl['Sortable:controller'] = () => {
+ return vm;
+ };
+
+ var nextSibling;
+
+ // Borrowed concept from, its not identical as more has been implemented: https://github.com/SortableJS/angular-legacy-sortablejs/blob/master/angular-legacy-sortable.js
+ function _sync(evt) {
+
+ const oldIndex = evt.oldIndex,
+ newIndex = evt.newIndex;
+
+ // If not the same gridLayoutContainerEl, then test for transfer option:
+ if (gridLayoutContainerEl !== evt.from) {
+ const fromCtrl = evt.from['Sortable:controller']();
+ const prevEntries = fromCtrl.entries;
+ const syncEntry = prevEntries[oldIndex];
+
+ // Perform the transfer:
+
+ if (Sortable.active && Sortable.active.lastPullMode === 'clone') {
+ syncEntry = Utilities.copy(syncEntry);
+ prevEntries.splice(Sortable.utils.index(evt.clone, sortable.options.draggable), 0, prevEntries.splice(oldIndex, 1)[0]);
+ }
+ else {
+ prevEntries.splice(oldIndex, 1);
+ }
+
+ vm.entries.splice(newIndex, 0, syncEntry);
+
+ const contextColumns = vm.blockEditorApi.internal.getContextColumns(vm.parentBlock, vm.areaKey);
+
+ // if colSpan is lower than contextColumns, and we do have some columnSpanOptions:
+ if (syncEntry.columnSpan < contextColumns && syncEntry.$block.config.columnSpanOptions.length > 0) {
+ // then check if the colSpan is a columnSpanOption, if NOT then reset to contextColumns.
+ const found = syncEntry.$block.config.columnSpanOptions.find(option => option.columnSpan === syncEntry.columnSpan);
+ if(!found) {
+ syncEntry.columnSpan = contextColumns;
+ }
+ } else {
+ syncEntry.columnSpan = contextColumns;
+ }
+
+ if(syncEntry.columnSpan === contextColumns) {
+ // If we are full width, then reset forceLeft/right.
+ syncEntry.forceLeft = false;
+ syncEntry.forceRight = false;
+ }
+
+ }
+ else {
+ vm.entries.splice(newIndex, 0, vm.entries.splice(oldIndex, 1)[0]);
+ }
+ }
+
+ function _indication(contextVM, movingEl) {
+
+ if(_lastIndicationContainerVM !== contextVM && _lastIndicationContainerVM !== null) {
+ _lastIndicationContainerVM.hideNotAllowed();
+ _lastIndicationContainerVM.revertIndicateDroppable();
+ }
+ _lastIndicationContainerVM = contextVM;
+
+ if(contextVM.acceptBlock(movingEl.dataset.contentElementTypeKey) === true) {
+ _lastIndicationContainerVM.hideNotAllowed();
+ _lastIndicationContainerVM.indicateDroppable();// This block is accepted to we will indicate a good drop.
+ return true;
+ }
+
+ contextVM.showNotAllowed();// This block is not accepted to we will indicate that its not allowed.
+
+ return false;
+ }
+
+ function _moveGhostElement() {
+
+ rqaId = null;
+ if(!ghostEl) {
+ return;
+ }
+ if(!approvedContainerEl) {
+ console.error("Cancel cause had no approvedContainerEl", approvedContainerEl)
+ return;
+ }
+
+ ghostRect = ghostEl.getBoundingClientRect();
+
+ const insideGhost = isWithinRect(dragX, dragY, ghostRect);
+ if (insideGhost) {
+ return;
+ }
+
+ var approvedContainerRect = approvedContainerEl.getBoundingClientRect();
+
+ const approvedContainerHasItems = approvedContainerEl.querySelector('.umb-block-grid__layout-item:not(.umb-block-grid__layout-item-placeholder)');
+ if(!approvedContainerHasItems && isWithinRect(dragX, dragY, approvedContainerRect, 20) || approvedContainerHasItems && isWithinRect(dragX, dragY, approvedContainerRect, -10)) {
+ // we are good...
+ } else {
+ var parentContainer = approvedContainerEl.parentNode.closest('.umb-block-grid__layout-container');
+ if(parentContainer) {
+
+ if(parentContainer['Sortable:controller']().sortGroupIdentifier === vm.sortGroupIdentifier) {
+ approvedContainerEl = parentContainer;
+ approvedContainerRect = approvedContainerEl.getBoundingClientRect();
+ }
+ }
+ }
+
+ // gather elements on the same row.
+ let elementInSameRow = [];
+ const containerElements = Array.from(approvedContainerEl.children);
+ for (const el of containerElements) {
+ const elRect = el.getBoundingClientRect();
+ // gather elements on the same row.
+ if(dragY >= elRect.top && dragY <= elRect.bottom && el !== ghostEl) {
+ elementInSameRow.push({el: el, rect:elRect});
+ }
+ }
+
+ let lastDistance = 99999;
+ let foundRelatedEl = null;
+ let placeAfter = false;
+ elementInSameRow.forEach( sameRow => {
+ const centerX = (sameRow.rect.left + (sameRow.rect.width*.5));
+ let distance = Math.abs(dragX - centerX);
+ if(distance < lastDistance) {
+ foundRelatedEl = sameRow.el;
+ lastDistance = Math.abs(distance);
+ placeAfter = dragX > centerX;
+ }
+ });
+
+ if (foundRelatedEl === ghostEl) {
+ return;
+ }
+
+ if (foundRelatedEl) {
+
+
+ let newIndex = containerElements.indexOf(foundRelatedEl);
+
+ const foundRelatedElRect = foundRelatedEl.getBoundingClientRect();
+
+ // Ghost is already on same line and we are not hovering the related element?
+ const ghostCenterY = ghostRect.top + (ghostRect.height*.5);
+ const isInsideFoundRelated = isWithinRect(dragX, dragY, foundRelatedElRect, 0);
+
+
+ if (isInsideFoundRelated && foundRelatedEl.classList.contains('--has-areas')) {
+ // If mouse is on top of an area, then make that the new approvedContainer?
+ const blockView = foundRelatedEl.querySelector('.umb-block-grid__block--view');
+ const subLayouts = blockView.querySelectorAll('.umb-block-grid__layout-container');
+ for (const subLayout of subLayouts) {
+ const subLayoutRect = subLayout.getBoundingClientRect();
+ const hasItems = subLayout.querySelector('.umb-block-grid__layout-item:not(.umb-block-grid__layout-item-placeholder)');
+ // gather elements on the same row.
+ if(!hasItems && isWithinRect(dragX, dragY, subLayoutRect, 20) || hasItems && isWithinRect(dragX, dragY, subLayoutRect, -10)) {
+
+ var subVm = subLayout['Sortable:controller']();
+ if(subVm.sortGroupIdentifier === vm.sortGroupIdentifier) {
+ approvedContainerEl = subLayout;
+ _moveGhostElement();
+ return;
+ }
+ }
+ }
+ }
+
+ if (ghostCenterY > foundRelatedElRect.top && ghostCenterY < foundRelatedElRect.bottom && !isInsideFoundRelated) {
+ return;
+ }
+
+ const containerVM = approvedContainerEl['Sortable:controller']();
+ if(_indication(containerVM, ghostEl) === false) {
+ return;
+ }
+
+ let verticalDirection = false;
+ if (ghostEl.dataset.forceLeft) {
+ placeAfter = true;
+ } else if (ghostEl.dataset.forceRight) {
+ placeAfter = true;
+ } else {
+
+ // if the related element is forceLeft and we are in the left side, we will set vertical direction, to correct placeAfter.
+ if (foundRelatedEl.dataset.forceLeft && placeAfter === false) {
+ verticalDirection = true;
+ } else
+ // if the related element is forceRight and we are in the right side, we will set vertical direction, to correct placeAfter.
+ if (foundRelatedEl.dataset.forceRight && placeAfter === true) {
+ verticalDirection = true;
+ } else {
+
+ // TODO: move calculations out so they can be persisted a bit longer?
+ //const approvedContainerRect = approvedContainerEl.getBoundingClientRect();
+ const approvedContainerComputedStyles = getComputedStyle(approvedContainerEl);
+ const gridColumnNumber = parseInt(approvedContainerComputedStyles.getPropertyValue("--umb-block-grid--grid-columns"), 10);
+
+ const relatedColumns = parseInt(foundRelatedEl.dataset.colSpan, 10);
+ const ghostColumns = parseInt(ghostEl.dataset.colSpan, 10);
+
+ // Get grid template:
+ const approvedContainerGridColumns = approvedContainerComputedStyles.gridTemplateColumns.trim().split("px").map(x => Number(x)).filter(n => n > 0);
+
+ // ensure all columns are there.
+ // This will also ensure handling non-css-grid mode,
+ // use container width divided by amount of columns( or the item width divided by its amount of columnSpan)
+ let amountOfColumnsInWeightMap = approvedContainerGridColumns.length;
+ const amountOfUnknownColumns = gridColumnNumber-amountOfColumnsInWeightMap;
+ if(amountOfUnknownColumns > 0) {
+ let accumulatedValue = getAccumulatedValueOfIndex(amountOfColumnsInWeightMap, approvedContainerGridColumns) || 0;
+ const layoutWidth = approvedContainerRect.width;
+ const missingColumnWidth = (layoutWidth-accumulatedValue)/amountOfUnknownColumns;
+ while(amountOfColumnsInWeightMap++ < gridColumnNumber) {
+ approvedContainerGridColumns.push(missingColumnWidth);
+ }
+ }
+
+
+ const relatedStartX = foundRelatedElRect.left - approvedContainerRect.left;
+ const relatedStartCol = Math.round(getInterpolatedIndexOfPositionInWeightMap(relatedStartX, approvedContainerGridColumns));
+
+ if(relatedStartCol + relatedColumns + ghostColumns > gridColumnNumber) {
+ verticalDirection = true;
+ }
+ }
+ }
+ if (verticalDirection) {
+ placeAfter = (dragY > foundRelatedElRect.top + (foundRelatedElRect.height*.5));
+ }
+
+
+ const nextEl = containerElements[(placeAfter ? newIndex+1 : newIndex)];
+ if (nextEl) {
+ approvedContainerEl.insertBefore(ghostEl, nextEl);
+ } else {
+ approvedContainerEl.appendChild(ghostEl);
+ }
+
+ return;
+ }
+
+ // If above or below container, we will go first or last.
+ const containerVM = approvedContainerEl['Sortable:controller']();
+ if(_indication(containerVM, ghostEl) === false) {
+ return;
+ }
+ if(dragY < approvedContainerRect.top) {
+ const firstEl = containerElements[0];
+ if (firstEl) {
+ approvedContainerEl.insertBefore(ghostEl, firstEl);
+ } else {
+ approvedContainerEl.appendChild(ghostEl);
+ }
+ } else if(dragY > approvedContainerRect.bottom) {
+ approvedContainerEl.appendChild(ghostEl);
+ }
+ }
+
+ var rqaId = null
+ function _onDragMove(evt) {
+
+ const clientX = (evt.touches ? evt.touches[0] : evt).clientX;
+ const clientY = (evt.touches ? evt.touches[1] : evt).clientY;
+ if(vm.movingLayoutEntry && targetRect && ghostRect && clientX !== 0 && clientY !== 0) {
+
+ if(dragX === clientX && dragY === clientY) {
+ return;
+ }
+ dragX = clientX;
+ dragY = clientY;
+
+ ghostRect = ghostEl.getBoundingClientRect();
+
+ const insideGhost = isWithinRect(dragX, dragY, ghostRect, 0);
+
+ if (!insideGhost) {
+ if(rqaId === null) {
+ rqaId = requestAnimationFrame(_moveGhostElement);
+ }
+ }
+
+
+ if(vm.movingLayoutEntry.columnSpan !== vm.layoutColumnsInt) {
+
+ const oldForceLeft = vm.movingLayoutEntry.forceLeft;
+ const oldForceRight = vm.movingLayoutEntry.forceRight;
+
+ var newValue = (dragX < targetRect.left);
+ if(newValue !== oldForceLeft) {
+ vm.movingLayoutEntry.forceLeft = newValue;
+ if(oldForceRight) {
+ vm.movingLayoutEntry.forceRight = false;
+ if(ghostElIndicateForceRight) {
+ ghostEl.removeChild(ghostElIndicateForceRight);
+ ghostElIndicateForceRight = null;
+ }
+ }
+ vm.blockEditorApi.internal.setDirty();
+ vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated
+ $scope.$evalAsync();
+
+ // Append element for indication, as angularJS lost connection:
+ if(newValue === true) {
+ ghostElIndicateForceLeft = document.createElement("div");
+ ghostElIndicateForceLeft.className = "indicateForceLeft";
+ ghostEl.appendChild(ghostElIndicateForceLeft);
+ } else if(ghostElIndicateForceLeft) {
+ ghostEl.removeChild(ghostElIndicateForceLeft);
+ ghostElIndicateForceLeft = null;
+ }
+ }
+
+ newValue = (dragX > targetRect.right) && (vm.movingLayoutEntry.forceLeft !== true);
+ if(newValue !== oldForceRight) {
+ vm.movingLayoutEntry.forceRight = newValue;
+ vm.blockEditorApi.internal.setDirty();
+ vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated
+ $scope.$evalAsync();
+
+ // Append element for indication, as angularJS lost connection:
+ if(newValue === true) {
+ ghostElIndicateForceRight = document.createElement("div");
+ ghostElIndicateForceRight.className = "indicateForceRight";
+ ghostEl.appendChild(ghostElIndicateForceRight);
+ } else if(ghostElIndicateForceRight) {
+ ghostEl.removeChild(ghostElIndicateForceRight);
+ ghostElIndicateForceRight = null;
+ }
+ }
+ }
+ }
+ }
+
+ vm.sortGroupIdentifier = "BlockGridEditor_"+vm.blockEditorApi.internal.uniqueEditorKey;
+
+ const sortable = Sortable.create(gridLayoutContainerEl, {
+ group: vm.sortGroupIdentifier,
+ sort: true,
+ animation: 0,
+ cancel: '',
+ draggable: ".umb-block-grid__layout-item",
+ ghostClass: "umb-block-grid__layout-item-placeholder",
+ swapThreshold: .4,
+ dragoverBubble: true,
+ emptyInsertThreshold: 40,
+
+ scrollSensitivity: 50,
+ scrollSpeed: 16,
+ scroll: true,
+ forceAutoScrollFallback: true,
+
+ onStart: function (evt) {
+ nextSibling = evt.from === evt.item.parentNode ? evt.item.nextSibling : evt.clone.nextSibling;
+
+ var contextVM = vm;
+ if (gridLayoutContainerEl !== evt.to) {
+ contextVM = evt.to['Sortable:controller']();
+ }
+
+ approvedContainerEl = evt.to;
+
+ const oldIndex = evt.oldIndex;
+ vm.movingLayoutEntry = contextVM.getLayoutEntryByIndex(oldIndex);
+ if(vm.movingLayoutEntry.forceLeft || vm.movingLayoutEntry.forceRight) {
+ // if one of these where true before, then we made a change here:
+ vm.blockEditorApi.internal.setDirty();
+ }
+ vm.movingLayoutEntry.forceLeft = false;
+ vm.movingLayoutEntry.forceRight = false;
+ vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated
+
+ ghostEl = evt.item;
+
+ targetRect = evt.to.getBoundingClientRect();
+ ghostRect = ghostEl.getBoundingClientRect();
+
+ const clientX = (evt.originalEvent.touches ? evt.originalEvent.touches[0] : evt.originalEvent).clientX;
+ dragOffsetX = clientX - ghostRect.left;
+
+ window.addEventListener('drag', _onDragMove);
+ window.addEventListener('dragover', _onDragMove);
+
+ document.documentElement.style.setProperty("--umb-block-grid--dragging-mode", 1);
+
+ $scope.$evalAsync();
+ },
+ // Called by any change to the list (add / update / remove)
+ onMove: function (evt) {
+ relatedEl = evt.related;
+ targetRect = evt.to.getBoundingClientRect();
+ ghostRect = evt.draggedRect;
+
+ // Disable SortableJS from handling the drop, instead we will use our own.
+ return false;
+ },
+ // When an change actually was made, after drop has occurred:
+ onSort: function (evt) {
+ vm.blockEditorApi.internal.setDirty();
+ },
+
+ onAdd: function (evt) {
+ _sync(evt);
+ $scope.$evalAsync();
+ },
+ onUpdate: function (evt) {
+ _sync(evt);
+ $scope.$evalAsync();
+ },
+ onEnd: function(evt) {
+ if(rqaId !== null) {
+ cancelAnimationFrame(rqaId);
+ }
+ window.removeEventListener('drag', _onDragMove);
+ window.removeEventListener('dragover', _onDragMove);
+
+ if(ghostElIndicateForceLeft) {
+ ghostEl.removeChild(ghostElIndicateForceLeft);
+ ghostElIndicateForceLeft = null;
+ }
+ if(ghostElIndicateForceRight) {
+ ghostEl.removeChild(ghostElIndicateForceRight);
+ ghostElIndicateForceRight = null;
+ }
+
+ // ensure not-allowed indication is removed.
+ if(_lastIndicationContainerVM) {
+ _lastIndicationContainerVM.hideNotAllowed();
+ _lastIndicationContainerVM.revertIndicateDroppable();
+ _lastIndicationContainerVM = null;
+ }
+
+ approvedContainerEl = null;
+ vm.movingLayoutEntry = null;
+ targetRect = null;
+ ghostRect = null;
+ ghostEl = null;
+ relatedEl = null;
+ }
+ });
+
+ $scope.$on('$destroy', function () {
+ sortable.destroy();
+ for (const subscription of unsubscribe) {
+ subscription();
+ }
+ });
+
+ };
+ }
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js
new file mode 100644
index 0000000000..22739d748c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js
@@ -0,0 +1,379 @@
+(function () {
+ "use strict";
+
+
+ /**
+ * Helper method that takes a weight map and finds the index of the value.
+ * @param {number} position - the value to find the index of.
+ * @param {number[]} weights - array of numbers each representing the weight/length.
+ * @returns {number} - the index of the weight that contains the accumulated value
+ */
+ function getInterpolatedIndexOfPositionInWeightMap(target, weights) {
+ const map = [0];
+ weights.reduce((a, b, i) => { return map[i+1] = a+b; }, 0);
+ const foundValue = map.reduce((a, b) => {
+ let aDiff = Math.abs(a - target);
+ let bDiff = Math.abs(b - target);
+
+ if (aDiff === bDiff) {
+ return a < b ? a : b;
+ } else {
+ return bDiff < aDiff ? b : a;
+ }
+ })
+ const foundIndex = map.indexOf(foundValue);
+ const targetDiff = (target-foundValue);
+ let interpolatedIndex = foundIndex;
+ if (targetDiff < 0 && foundIndex === 0) {
+ // Don't adjust.
+ } else if (targetDiff > 0 && foundIndex === map.length-1) {
+ // Don't adjust.
+ } else {
+ const foundInterpolationWeight = weights[targetDiff >= 0 ? foundIndex : foundIndex-1];
+ interpolatedIndex += foundInterpolationWeight === 0 ? interpolatedIndex : (targetDiff/foundInterpolationWeight)
+ }
+ return interpolatedIndex;
+ }
+
+ function getAccumulatedValueOfIndex(index, weights) {
+ let i = 0, len = Math.min(index, weights.length), calc = 0;
+ while(i {
+ if (a.columnSpan > max) {
+ return b;
+ }
+ let aDiff = Math.abs(a.columnSpan - target);
+ let bDiff = Math.abs(b.columnSpan - target);
+
+ if (aDiff === bDiff) {
+ return a.columnSpan < b.columnSpan ? a : b;
+ } else {
+ return bDiff < aDiff ? b : a;
+ }
+ });
+ if(result) {
+ return result;
+ }
+ return max;
+ }
+
+
+
+ /**
+ * @ngdoc directive
+ * @name umbraco.directives.directive:umbBlockGridEntry
+ * @description
+ * renders each row for the block grid editor
+ */
+ angular
+ .module("umbraco")
+ .component("umbBlockGridEntry", {
+ templateUrl: 'views/propertyeditors/blockgrid/umb-block-grid-entry.html',
+ controller: BlockGridEntryController,
+ controllerAs: "vm",
+ bindings: {
+ blockEditorApi: "<",
+ layoutColumns: "<",
+ layoutEntry: "<",
+ index: "<",
+ parentBlock: "<",
+ areaKey: "<",
+ propertyEditorForm: "",
+ depth: "@"
+ }
+ }
+ );
+
+ function BlockGridEntryController($scope, $element) {
+
+ const unsubscribe = [];
+ const vm = this;
+ vm.areaGridColumns = '';
+ vm.isHoveringArea = false;
+ vm.isScaleMode = false;
+ vm.layoutColumnsInt = 0;
+ vm.hideInlineCreateAfter = true;
+
+ vm.$onInit = function() {
+
+ vm.childDepth = parseInt(vm.depth) + 1;
+
+ if(vm.layoutEntry.$block.config.areaGridColumns) {
+ vm.areaGridColumns = vm.layoutEntry.$block.config.areaGridColumns.toString();
+ } else {
+ vm.areaGridColumns = vm.blockEditorApi.internal.gridColumns.toString();
+ }
+
+ vm.layoutColumnsInt = parseInt(vm.layoutColumns, 10)
+
+ $scope.$evalAsync();
+ }
+ unsubscribe.push($scope.$watch("depth", (newVal, oldVal) => {
+ vm.childDepth = parseInt(vm.depth) + 1;
+ }));
+ /**
+ * We want to only show the validation errors on the specific Block, not the parent blocks.
+ * So we need to avoid having a Block as the parent to the Block Form.
+ * Therefor we skip any parent blocks forms, and sets the parent form to the property editor.
+ */
+ vm.$postLink = function() {
+ // If parent form is not the property editor form, then its another Block Forms and we will change it.
+ if(vm.blockForm.$$parentForm !== vm.propertyEditorForm) {
+ // Remove from parent block:
+ vm.blockForm.$$parentForm.$removeControl(vm.blockForm);
+ // Connect with property editor form:
+ vm.propertyEditorForm.$addControl(vm.blockForm);
+ }
+ }
+ vm.mouseOverArea = function(area) {
+ if(area.items.length > 0) {
+ vm.isHoveringArea = true;
+ }
+ }
+ vm.mouseLeaveArea = function() {
+ vm.isHoveringArea = false;
+ }
+ vm.toggleForceLeft = function() {
+ vm.layoutEntry.forceLeft = !vm.layoutEntry.forceLeft;
+ if(vm.layoutEntry.forceLeft) {
+ vm.layoutEntry.forceRight = false;
+ }
+ vm.blockEditorApi.internal.setDirty();
+ }
+ vm.toggleForceRight = function() {
+ vm.layoutEntry.forceRight = !vm.layoutEntry.forceRight;
+ if(vm.layoutEntry.forceRight) {
+ vm.layoutEntry.forceLeft = false;
+ }
+ vm.blockEditorApi.internal.setDirty();
+ }
+
+ // Block sizing functionality:
+ let layoutContainer = null;
+ let gridColumns = null;
+ let gridRows = null;
+ let scaleBoxBackdropEl = null;
+
+
+ function getNewSpans(startX, startY, endX, endY) {
+
+ const blockStartCol = Math.round(getInterpolatedIndexOfPositionInWeightMap(startX, gridColumns));
+ const blockStartRow = Math.round(getInterpolatedIndexOfPositionInWeightMap(startY, gridRows));
+ const blockEndCol = getInterpolatedIndexOfPositionInWeightMap(endX, gridColumns);
+ const blockEndRow = getInterpolatedIndexOfPositionInWeightMap(endY, gridRows);
+
+ let newColumnSpan = Math.max(blockEndCol-blockStartCol, 1);
+
+ // Find nearest allowed Column:
+ newColumnSpan = closestColumnSpanOption(newColumnSpan , vm.layoutEntry.$block.config.columnSpanOptions, gridColumns.length - blockStartCol).columnSpan;
+
+ let newRowSpan = Math.round(Math.max(blockEndRow-blockStartRow, vm.layoutEntry.$block.config.rowMinSpan || 1));
+ if(vm.layoutEntry.$block.config.rowMaxSpan != null) {
+ newRowSpan = Math.min(newRowSpan, vm.layoutEntry.$block.config.rowMaxSpan);
+ }
+
+ return {'columnSpan': newColumnSpan, 'rowSpan': newRowSpan, 'startCol': blockStartCol, 'startRow': blockStartRow};
+ }
+
+ function updateGridLayoutData(layoutContainerRect, layoutItemRect) {
+
+ const computedStyles = window.getComputedStyle(layoutContainer);
+
+ gridColumns = computedStyles.gridTemplateColumns.trim().split("px").map(x => Number(x));
+ gridRows = computedStyles.gridTemplateRows.trim().split("px").map(x => Number(x));
+
+ // remove empties:
+ gridColumns = gridColumns.filter(n => n > 0);
+ gridRows = gridRows.filter(n => n > 0);
+
+ // ensure all columns are there.
+ // This will also ensure handling non-css-grid mode,
+ // use container width divided by amount of columns( or the item width divided by its amount of columnSpan)
+ let amountOfColumnsInWeightMap = gridColumns.length;
+ let gridColumnNumber = parseInt(computedStyles.getPropertyValue('--umb-block-grid--grid-columns'));
+ const amountOfUnknownColumns = gridColumnNumber-amountOfColumnsInWeightMap;
+ if(amountOfUnknownColumns > 0) {
+ let accumulatedValue = getAccumulatedValueOfIndex(amountOfColumnsInWeightMap, gridColumns) || 0;
+ const layoutWidth = layoutContainerRect.width;
+ const missingColumnWidth = (layoutWidth-accumulatedValue)/amountOfUnknownColumns;
+ while(amountOfColumnsInWeightMap++ < gridColumnNumber) {
+ gridColumns.push(missingColumnWidth);
+ }
+ }
+
+
+ // Handle non css grid mode for Rows:
+ // use item height divided by rowSpan to identify row heights.
+ if(gridRows.length === 0) {
+ // Push its own height twice, to give something to scale with.
+ gridRows.push(layoutItemRect.top - layoutContainerRect.top);
+
+ let i = 0;
+ const itemSingleRowHeight = layoutItemRect.height;
+ while(i++ < vm.layoutEntry.rowSpan) {
+ gridRows.push(itemSingleRowHeight);
+ }
+ }
+
+ // add a few extra rows, so there is something to extend too.
+ // Add extra options for the ability to extend beyond current content:
+ gridRows.push(50);
+ gridRows.push(50);
+ gridRows.push(50);
+ }
+
+ vm.scaleHandlerMouseDown = function($event) {
+ $event.originalEvent.preventDefault();
+ vm.isScaleMode = true;
+
+ window.addEventListener('mousemove', vm.onMouseMove);
+ window.addEventListener('mouseup', vm.onMouseUp);
+ window.addEventListener('mouseleave', vm.onMouseUp);
+
+
+ layoutContainer = $element[0].closest('.umb-block-grid__layout-container');
+ if(!layoutContainer) {
+ console.error($element[0], 'could not find parent layout-container');
+ }
+
+ const layoutContainerRect = layoutContainer.getBoundingClientRect();
+ const layoutItemRect = $element[0].getBoundingClientRect();
+ updateGridLayoutData(layoutContainerRect, layoutItemRect);
+
+
+ scaleBoxBackdropEl = document.createElement('div');
+ scaleBoxBackdropEl.className = 'umb-block-grid__scalebox-backdrop';
+ layoutContainer.appendChild(scaleBoxBackdropEl);
+
+ }
+ vm.onMouseMove = function(e) {
+
+ const layoutContainerRect = layoutContainer.getBoundingClientRect();
+ const layoutItemRect = $element[0].getBoundingClientRect();
+ updateGridLayoutData(layoutContainerRect, layoutItemRect);
+
+
+ const startX = layoutItemRect.left - layoutContainerRect.left;
+ const startY = layoutItemRect.top - layoutContainerRect.top;
+ const endX = e.clientX - layoutContainerRect.left;
+ const endY = e.clientY - layoutContainerRect.top;
+
+ const newSpans = getNewSpans(startX, startY, endX, endY);
+
+ // update as we go:
+ vm.layoutEntry.columnSpan = newSpans.columnSpan;
+ vm.layoutEntry.rowSpan = newSpans.rowSpan;
+
+ $scope.$evalAsync();
+ }
+
+ vm.onMouseUp = function(e) {
+
+ vm.isScaleMode = false;
+
+ const layoutContainerRect = layoutContainer.getBoundingClientRect();
+ const layoutItemRect = $element[0].getBoundingClientRect();
+
+ const startX = layoutItemRect.left - layoutContainerRect.left;
+ const startY = layoutItemRect.top - layoutContainerRect.top;
+ const endX = e.clientX - layoutContainerRect.left;
+ const endY = e.clientY - layoutContainerRect.top;
+
+ const newSpans = getNewSpans(startX, startY, endX, endY);
+
+ // Remove listeners:
+ window.removeEventListener('mousemove', vm.onMouseMove);
+ window.removeEventListener('mouseup', vm.onMouseUp);
+ window.removeEventListener('mouseleave', vm.onMouseUp);
+
+ layoutContainer.removeChild(scaleBoxBackdropEl);
+
+ // Clean up variables:
+ layoutContainer = null;
+ gridColumns = null;
+ gridRows = null;
+ scaleBoxBackdropEl = null;
+
+ // Update block size:
+ vm.layoutEntry.columnSpan = newSpans.columnSpan;
+ vm.layoutEntry.rowSpan = newSpans.rowSpan;
+ vm.blockEditorApi.internal.setDirty();
+ $scope.$evalAsync();
+ }
+
+
+
+ vm.scaleHandlerKeyUp = function($event) {
+
+ let addCol = 0;
+ let addRow = 0;
+
+ switch ($event.originalEvent.key) {
+ case 'ArrowUp':
+ addRow = -1;
+ break;
+ case 'ArrowDown':
+ addRow = 1;
+ break;
+ case 'ArrowLeft':
+ addCol = -1;
+ break;
+ case 'ArrowRight':
+ addCol = 1;
+ break;
+ }
+
+ const newColumnSpan = Math.max(vm.layoutEntry.columnSpan + addCol, 1);
+
+ vm.layoutEntry.columnSpan = closestColumnSpanOption(newColumnSpan, vm.layoutEntry.$block.config.columnSpanOptions, gridColumns.length).columnSpan;
+ let newRowSpan = Math.max(vm.layoutEntry.rowSpan + addRow, vm.layoutEntry.$block.config.rowMinSpan || 1);
+ if(vm.layoutEntry.$block.config.rowMaxSpan != null) {
+ newRowSpan = Math.min(newRowSpan, vm.layoutEntry.$block.config.rowMaxSpan);
+ }
+ vm.layoutEntry.rowSpan = newRowSpan;
+
+ vm.blockEditorApi.internal.setDirty();
+ $event.originalEvent.stopPropagation();
+ }
+
+
+ vm.clickInlineCreateAfter = function($event) {
+ if(vm.hideInlineCreateAfter === false) {
+ vm.blockEditorApi.requestShowCreate(vm.parentBlock, vm.areaKey, vm.index+1, $event, {'fitInRow': true});
+ }
+ }
+ vm.mouseOverInlineCreateAfter = function() {
+
+ layoutContainer = $element[0].closest('.umb-block-grid__layout-container');
+ if(!layoutContainer) {
+ return;
+ }
+
+ const layoutContainerRect = layoutContainer.getBoundingClientRect();
+ const layoutItemRect = $element[0].getBoundingClientRect();
+
+ if(layoutItemRect.right > layoutContainerRect.right - 5) {
+ vm.hideInlineCreateAfter = true;
+ return;
+ }
+
+ vm.hideInlineCreateAfter = false;
+ vm.blockEditorApi.internal.showAreaHighlight(vm.parentBlock, vm.areaKey);
+
+ }
+
+ $scope.$on("$destroy", function () {
+ for (const subscription of unsubscribe) {
+ subscription();
+ }
+ });
+
+ }
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js
new file mode 100644
index 0000000000..130150a3ae
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js
@@ -0,0 +1,64 @@
+(function () {
+ "use strict";
+
+ /**
+ * @ngdoc directive
+ * @name umbraco.directives.directive:umbBlockGridRoot
+ * @description
+ * The component to render the view for a block grid in the Property Editor.
+ * Creates a ShadowDom for the layout.
+ */
+
+ angular
+ .module("umbraco")
+ .component("umbBlockGridRoot", {
+ controller: umbBlockGridRootController,
+ controllerAs: "vm",
+ bindings: {
+ gridColumns: "@",
+ createLabel: "@",
+ stylesheet: "@",
+ blockEditorApi: "<",
+ propertyEditorForm: "",
+ entries: "<"
+ }
+ }
+ );
+
+ function umbBlockGridRootController($scope, $compile, $element) {
+ var vm = this;
+
+ vm.$onInit = function () {
+
+ var shadowRoot = $element[0].attachShadow({ mode: 'open' });
+ shadowRoot.innerHTML =
+ `
+
+
+
+
+
+ `;
+ $compile(shadowRoot)($scope);
+
+ };
+ }
+
+
+})();
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css
new file mode 100644
index 0000000000..94974a9111
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css
@@ -0,0 +1,36 @@
+/** Example of how a grid layout stylehseet could be done with Flex box: */
+
+
+.umb-block-grid__layout-container {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+}
+.umb-block-grid__layout-item {
+ --umb-block-grid__layout-item-calc: calc(var(--umb-block-grid--item-column-span) / var(--umb-block-grid--grid-columns));
+ width: calc(var(--umb-block-grid__layout-item-calc) * 100%);
+}
+.umb-block-grid__layout-item[data-force-left] {
+ align-self: flex-start;
+}
+.umb-block-grid__layout-item[data-force-left]::before {
+ content: '';
+ flex-basis: 100%;
+ height: 0;
+}
+.umb-block-grid__layout-item[data-force-right] {
+ margin-left: auto;
+ align-self: flex-end;
+}
+
+
+.umb-block-grid__area-container, .umb-block-grid__block--view::part(area-container) {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+}
+.umb-block-grid__area {
+ --umb-block-grid__area-calc: calc(var(--umb-block-grid--area-column-span) / var(--umb-block-grid--area-grid-columns, 1));
+ width: calc(var(--umb-block-grid__area-calc) * 100%);
+}
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css
new file mode 100644
index 0000000000..91cb751f58
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css
@@ -0,0 +1,46 @@
+.umb-block-grid__layout-container {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(var(--umb-block-grid--grid-columns, 1), minmax(0, 1fr));
+ grid-gap: 0px;
+ grid-auto-flow: row;
+ grid-auto-rows: minmax(50px, min-content);
+}
+.umb-block-grid__layout-item {
+ position: relative;
+ /* For small devices we scale columnSpan by three, to make everything bigger than 1/3 take full width: */
+ grid-column-end: span min(calc(var(--umb-block-grid--item-column-span, 1) * 3), var(--umb-block-grid--grid-columns));
+ grid-row: span var(--umb-block-grid--item-row-span, 1);
+}
+.umb-block-grid__layout-item[data-force-left] {
+ grid-column-start: 1;
+}
+.umb-block-grid__layout-item[data-force-right] {
+ grid-column-start: calc(1 + var(--umb-block-grid--grid-columns) - var(--umb-block-grid--item-column-span));
+}
+
+
+.umb-block-grid__area-container, .umb-block-grid__block--view::part(area-container) {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(var(--umb-block-grid--area-grid-columns, var(--umb-block-grid--grid-columns, 1)), minmax(0, 1fr));
+ grid-gap: 0px;
+ grid-auto-flow: row;
+ grid-auto-rows: minmax(50px, min-content);
+ width: 100%;
+}
+.umb-block-grid__area {
+ position: relative;
+ /* For small devices we scale columnSpan by three, to make everything bigger than 1/3 take full width: */
+ grid-column-end: span min(calc(var(--umb-block-grid--item-column-span, 1) * 3), var(--umb-block-grid--grid-columns));
+ grid-row: span var(--umb-block-grid--area-row-span, 1);
+}
+
+@media (min-width:1024px) {
+ .umb-block-grid__layout-item {
+ grid-column-end: span var(--umb-block-grid--item-column-span, 1);
+ }
+ .umb-block-grid__area {
+ grid-column-end: span var(--umb-block-grid--area-column-span, 1);
+ }
+}
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html
index 67e557bff4..8f6042613d 100644
--- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html
@@ -1,4 +1,4 @@
-
+
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less
index 8d063c77fb..d2d875aa94 100644
--- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less
@@ -16,6 +16,9 @@
opacity: 0;
transition: opacity 120ms;
}
+ .controls:hover &,
+ .controls:focus &,
+ .controls:focus-within &,
.control-group:hover,
.control-group:focus,
.control-group:focus-within {
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js
index c71773a04b..ca9efe0382 100644
--- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js
@@ -586,7 +586,7 @@
var blockObject = vm.layout[createIndex].$block;
if (inlineEditing === true) {
blockObject.activate();
- } else if (inlineEditing === false && blockObject.hideContentInOverlay !== true) {
+ } else if (inlineEditing === false && blockObject.hideContentInOverlay !== true && blockObject.content.variants[0].tabs[0]?.properties.length > 0) {
vm.options.createFlow = true;
blockObject.edit();
vm.options.createFlow = false;
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js
index bcfbda4759..25ca237bb9 100644
--- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js
+++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js
@@ -463,7 +463,7 @@
}
}
- unsubscribe.push($scope.$watch(() => vm.model.value.length, onAmountOfMediaChanged));
+ unsubscribe.push($scope.$watch(() => vm.model.value?.length || 0, onAmountOfMediaChanged));
$scope.$on("$destroy", function () {
for (const subscription of unsubscribe) {
diff --git a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js
index 6619754315..89101d2f1d 100644
--- a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js
+++ b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js
@@ -29,6 +29,7 @@ module.exports = function (config) {
'lib/umbraco/Extensions.js',
'node_modules/lazyload-js/LazyLoad.min.js',
'node_modules/angular-dynamic-locale/dist/tmhDynamicLocale.min.js',
+ 'node_modules/sortablejs/Sortable.min.js',
//app bootstrap and loader
'test/config/app.unit.js',
diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
index b5767d5f81..f8650658b5 100644
--- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
+++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
@@ -9,6 +9,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -66,5 +79,9 @@
+
+
+
+
diff --git a/src/Umbraco.Web.UI/package-lock.json b/src/Umbraco.Web.UI/package-lock.json
new file mode 100644
index 0000000000..daa5a8007f
--- /dev/null
+++ b/src/Umbraco.Web.UI/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "Umbraco.Web.UI",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {}
+}
diff --git a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs
index 13a606b28b..2e44bae31f 100644
--- a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs
+++ b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs
@@ -391,6 +391,28 @@ public static class HtmlHelperRenderExtensions
string action,
string controllerName,
object? additionalRouteVals,
+ IDictionary htmlAttributes) => html.BeginUmbracoForm(action, controllerName, additionalRouteVals, htmlAttributes, FormMethod.Post);
+
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller
+ ///
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ string controllerName,
+ object? additionalRouteVals,
+ IDictionary htmlAttributes,
+ FormMethod method) => html.BeginUmbracoForm(action, controllerName, additionalRouteVals, null, htmlAttributes, method);
+
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller
+ ///
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ string controllerName,
+ object? additionalRouteVals,
+ bool? antiforgery,
IDictionary htmlAttributes,
FormMethod method)
{
@@ -418,44 +440,7 @@ public static class HtmlHelperRenderExtensions
nameof(controllerName));
}
- return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, htmlAttributes, method);
- }
-
- ///
- /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller
- ///
- public static MvcForm BeginUmbracoForm(
- this IHtmlHelper html,
- string action,
- string controllerName,
- object? additionalRouteVals,
- IDictionary htmlAttributes)
- {
- if (action == null)
- {
- throw new ArgumentNullException(nameof(action));
- }
-
- if (string.IsNullOrWhiteSpace(action))
- {
- throw new ArgumentException(
- "Value can't be empty or consist only of white-space characters.",
- nameof(action));
- }
-
- if (controllerName == null)
- {
- throw new ArgumentNullException(nameof(controllerName));
- }
-
- if (string.IsNullOrWhiteSpace(controllerName))
- {
- throw new ArgumentException(
- "Value can't be empty or consist only of white-space characters.",
- nameof(controllerName));
- }
-
- return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, htmlAttributes);
+ return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, antiforgery, htmlAttributes, method);
}
///
@@ -477,6 +462,13 @@ public static class HtmlHelperRenderExtensions
public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, FormMethod method)
where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), method);
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
+ ///
+ /// The type
+ public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, FormMethod method, bool? antiforgery)
+ where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), null, antiforgery, new Dictionary(), method);
+
///
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
///
@@ -524,6 +516,21 @@ public static class HtmlHelperRenderExtensions
public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, object additionalRouteVals)
where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), additionalRouteVals);
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
+ ///
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ Type surfaceType,
+ object additionalRouteVals,
+ object htmlAttributes) =>
+ html.BeginUmbracoForm(
+ action,
+ surfaceType,
+ additionalRouteVals,
+ HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
+
///
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
///
@@ -549,12 +556,16 @@ public static class HtmlHelperRenderExtensions
string action,
Type surfaceType,
object additionalRouteVals,
- object htmlAttributes) =>
+ object htmlAttributes,
+ FormMethod method,
+ bool? antiforgery) =>
html.BeginUmbracoForm(
action,
surfaceType,
additionalRouteVals,
- HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
+ antiforgery,
+ HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes),
+ method);
///
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
@@ -569,6 +580,20 @@ public static class HtmlHelperRenderExtensions
where T : SurfaceController =>
html.BeginUmbracoForm(action, typeof(T), additionalRouteVals, htmlAttributes, method);
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
+ ///
+ /// The type
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ object additionalRouteVals,
+ object htmlAttributes,
+ FormMethod method,
+ bool? antiforgery)
+ where T : SurfaceController =>
+ html.BeginUmbracoForm(action, typeof(T), additionalRouteVals, htmlAttributes, method, antiforgery);
+
///
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
///
@@ -588,6 +613,18 @@ public static class HtmlHelperRenderExtensions
Type surfaceType,
object? additionalRouteVals,
IDictionary htmlAttributes,
+ FormMethod method) => html.BeginUmbracoForm(action, surfaceType, additionalRouteVals, null, htmlAttributes, method);
+
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
+ ///
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ Type surfaceType,
+ object? additionalRouteVals,
+ bool? antiforgery,
+ IDictionary htmlAttributes,
FormMethod method)
{
if (action == null)
@@ -630,6 +667,7 @@ public static class HtmlHelperRenderExtensions
metaData.ControllerName,
area!,
additionalRouteVals,
+ antiforgery,
htmlAttributes,
method);
}
@@ -673,7 +711,7 @@ public static class HtmlHelperRenderExtensions
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
///
public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, string controllerName, string area, FormMethod method)
- => html.BeginUmbracoForm(action, controllerName, area, null, new Dictionary(), method);
+ => html.BeginUmbracoForm(action, controllerName, area, additionalRouteVals: null, new Dictionary(), method);
///
/// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
@@ -692,6 +730,20 @@ public static class HtmlHelperRenderExtensions
object? additionalRouteVals,
IDictionary htmlAttributes,
FormMethod method)
+ => html.BeginUmbracoForm(action, controllerName, area, additionalRouteVals, null, htmlAttributes, method);
+
+ ///
+ /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin
+ ///
+ public static MvcForm BeginUmbracoForm(
+ this IHtmlHelper html,
+ string action,
+ string? controllerName,
+ string area,
+ object? additionalRouteVals,
+ bool? antiforgery,
+ IDictionary htmlAttributes,
+ FormMethod method)
{
if (action == null)
{
@@ -718,7 +770,7 @@ public static class HtmlHelperRenderExtensions
IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService(html);
IUmbracoContext umbracoContext = umbracoContextAccessor.GetRequiredUmbracoContext();
var formAction = umbracoContext.OriginalRequestUrl.PathAndQuery;
- return html.RenderForm(formAction, method, htmlAttributes, controllerName, action, area, additionalRouteVals);
+ return html.RenderForm(formAction, method, htmlAttributes, controllerName, action, area, antiforgery, additionalRouteVals);
}
///
@@ -753,6 +805,7 @@ public static class HtmlHelperRenderExtensions
string surfaceController,
string surfaceAction,
string area,
+ bool? antiforgery = null,
object? additionalRouteVals = null)
{
// ensure that the multipart/form-data is added to the HTML attributes
@@ -781,7 +834,7 @@ public static class HtmlHelperRenderExtensions
HtmlEncoder htmlEncoder = GetRequiredService(htmlHelper);
// new UmbracoForm:
- var theForm = new UmbracoForm(htmlHelper.ViewContext, htmlEncoder, surfaceController, surfaceAction, area, additionalRouteVals);
+ var theForm = new UmbracoForm(htmlHelper.ViewContext, htmlEncoder, surfaceController, surfaceAction, area, antiforgery, additionalRouteVals);
if (traditionalJavascriptEnabled)
{
@@ -798,6 +851,7 @@ public static class HtmlHelperRenderExtensions
{
private readonly string _surfaceControllerInput;
private readonly ViewContext _viewContext;
+ private readonly bool? _antiforgery;
///
/// Initializes a new instance of the class.
@@ -808,10 +862,12 @@ public static class HtmlHelperRenderExtensions
string controllerName,
string controllerAction,
string area,
+ bool? antiforgery = null,
object? additionalRouteVals = null)
: base(viewContext, htmlEncoder)
{
_viewContext = viewContext;
+ _antiforgery = antiforgery;
_surfaceControllerInput = GetSurfaceControllerHiddenInput(
GetRequiredService(viewContext),
controllerName,
@@ -822,10 +878,13 @@ public static class HtmlHelperRenderExtensions
protected override void GenerateEndForm()
{
- // Always output an anti-forgery token
- IAntiforgery antiforgery = _viewContext.HttpContext.RequestServices.GetRequiredService();
- IHtmlContent antiforgeryHtml = antiforgery.GetHtml(_viewContext.HttpContext);
- _viewContext.Writer.Write(antiforgeryHtml.ToHtmlString());
+ // Always output an anti-forgery token unless explicitly requested to omit.
+ if (!_antiforgery.HasValue || _antiforgery.Value)
+ {
+ IAntiforgery antiforgery = _viewContext.HttpContext.RequestServices.GetRequiredService();
+ IHtmlContent antiforgeryHtml = antiforgery.GetHtml(_viewContext.HttpContext);
+ _viewContext.Writer.Write(antiforgeryHtml.ToHtmlString());
+ }
// write out the hidden surface form routes
_viewContext.Writer.Write(_surfaceControllerInput);
diff --git a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj
index e98070719d..fb9b051667 100644
--- a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj
+++ b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj
@@ -3,6 +3,7 @@
Umbraco.Cms.Web.WebsiteUmbraco CMS - Web - WebsiteContains the website assembly needed to run the frontend of Umbraco CMS.
+ LibraryUmbraco.Cms.Web.Website
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index 62031665f7..9768d3ee8e 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -3,6 +3,7 @@
+
annotations
diff --git a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker
index e3f723c137..97777a0865 100644
--- a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker
+++ b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker
@@ -6,6 +6,8 @@ FROM mcr.microsoft.com/dotnet/nightly/sdk:7.0 AS build
COPY nuget.config .
+COPY nuget.config .
+
WORKDIR /nupkg
COPY nupkg .
diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json
index 72f6c5904e..70229528bb 100644
--- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json
+++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json
@@ -8,7 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^1.0.0",
- "@umbraco/playwright-testhelpers": "^1.0.1",
+ "@umbraco/playwright-testhelpers": "^1.0.2",
"camelize": "^1.0.0",
"dotenv": "^16.0.2",
"faker": "^4.1.0",
@@ -101,11 +101,11 @@
}
},
"node_modules/@umbraco/playwright-testhelpers": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.1.tgz",
- "integrity": "sha512-o3UnVpIlwd9KMKp5Hnv31cUBCkzzIagFY2quQsMFeVfaKXr7Ku1+3egArB9S3bwQhz3aan0jzlmwIp9D9r8vxg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.2.tgz",
+ "integrity": "sha512-j1y6YRq2Rg5AXyYk/304P2rTrDCLU7Sz67/MMfkPBHSvadjdof7EW8649Aio29xGAg1YAR4y+Zeyw6XnM35ZkA==",
"dependencies": {
- "@umbraco/playwright-models": "^5.0.0",
+ "@umbraco/json-models-builders": "^1.0.0",
"camelize": "^1.0.0",
"faker": "^4.1.0",
"form-data": "^4.0.0",
@@ -113,15 +113,6 @@
"xhr2": "^0.2.1"
}
},
- "node_modules/@umbraco/playwright-testhelpers/node_modules/@umbraco/playwright-models": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@umbraco/playwright-models/-/playwright-models-5.0.0.tgz",
- "integrity": "sha512-HOf81JzlGysH9MoZTOH77jjHBEjveTMcxQRpyIfXfQmjdOar6nrEv5MPBMXwgiizLwnkhQBFkRuzKA/YASQnAg==",
- "dependencies": {
- "camelize": "^1.0.0",
- "faker": "^4.1.0"
- }
- },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -915,27 +906,16 @@
}
},
"@umbraco/playwright-testhelpers": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.1.tgz",
- "integrity": "sha512-o3UnVpIlwd9KMKp5Hnv31cUBCkzzIagFY2quQsMFeVfaKXr7Ku1+3egArB9S3bwQhz3aan0jzlmwIp9D9r8vxg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.2.tgz",
+ "integrity": "sha512-j1y6YRq2Rg5AXyYk/304P2rTrDCLU7Sz67/MMfkPBHSvadjdof7EW8649Aio29xGAg1YAR4y+Zeyw6XnM35ZkA==",
"requires": {
- "@umbraco/playwright-models": "^5.0.0",
+ "@umbraco/json-models-builders": "^1.0.0",
"camelize": "^1.0.0",
"faker": "^4.1.0",
"form-data": "^4.0.0",
"node-fetch": "^2.6.7",
"xhr2": "^0.2.1"
- },
- "dependencies": {
- "@umbraco/playwright-models": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@umbraco/playwright-models/-/playwright-models-5.0.0.tgz",
- "integrity": "sha512-HOf81JzlGysH9MoZTOH77jjHBEjveTMcxQRpyIfXfQmjdOar6nrEv5MPBMXwgiizLwnkhQBFkRuzKA/YASQnAg==",
- "requires": {
- "camelize": "^1.0.0",
- "faker": "^4.1.0"
- }
- }
}
},
"aggregate-error": {
diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json
index b48fafb781..8996b7dbaa 100644
--- a/tests/Umbraco.Tests.AcceptanceTest/package.json
+++ b/tests/Umbraco.Tests.AcceptanceTest/package.json
@@ -19,7 +19,7 @@
},
"dependencies": {
"@umbraco/json-models-builders": "^1.0.0",
- "@umbraco/playwright-testhelpers": "^1.0.1",
+ "@umbraco/playwright-testhelpers": "^1.0.2",
"camelize": "^1.0.0",
"faker": "^4.1.0",
"form-data": "^4.0.0",
diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts
new file mode 100644
index 0000000000..1da0c016f0
--- /dev/null
+++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts
@@ -0,0 +1,185 @@
+import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers';
+import {expect} from "@playwright/test";
+import {
+ ApprovedColorPickerDataTypeBuilder,
+ DocumentTypeBuilder,
+ TextBoxDataTypeBuilder
+} from "@umbraco/json-models-builders";
+
+test.describe('DataTypes', () => {
+
+ test.beforeEach(async ({page, umbracoApi}) => {
+ await umbracoApi.login();
+ });
+
+ test('Tests Approved Colors', async ({page, umbracoApi, umbracoUi}) => {
+ const name = 'Approved Colour Test';
+ const alias = AliasHelper.toAlias(name);
+
+ await umbracoApi.documentTypes.ensureNameNotExists(name);
+ await umbracoApi.content.deleteAllContent();
+ await umbracoApi.dataTypes.ensureNameNotExists(name);
+ await umbracoApi.templates.ensureNameNotExists(name);
+
+ const pickerDataType = new ApprovedColorPickerDataTypeBuilder()
+ .withName(name)
+ .withPrevalues(['000000', 'FF0000'])
+ .build()
+ await umbracoApi.content.createDocTypeWithContent(name, alias, pickerDataType);
+
+ // This is an ugly wait, but we have to wait for cache to rebuild
+ await page.waitForTimeout(5000);
+
+ // Editing template with some content
+ await umbracoApi.templates.edit(name,
+ '@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage' +
+ '\n@{' +
+ '\n Layout = null;' +
+ '\n}' +
+ '\n