Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: No Blocks Mode for RTE when required context for Blocks is not present #15556

Merged
merged 2 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check notice on line 1 in src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

ℹ Getting worse: Lines of Code in a Single File

The lines of code increases from 1250 to 1255, improve code health by reducing it to 1000. The number of Lines of Code in a single file. More Lines of Code lowers the code health.

Check notice on line 1 in src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

ℹ Getting worse: Overall Code Complexity

The mean cyclomatic complexity increases from 9.26 to 9.35, threshold = 4. This file has many conditional statements (e.g. if, for, while) across its implementation, leading to lower code health. Avoid adding more conditionals.
* @ngdoc service
* @name umbraco.services.tinyMceService
*
Expand Down Expand Up @@ -1439,323 +1439,329 @@

function initBlocks() {

if(!args.blockEditorApi) {
return;
}

const blockEls = args.editor.contentDocument.querySelectorAll('umb-rte-block, umb-rte-block-inline');
for (var blockEl of blockEls) {
if(!blockEl._isInitializedUmbBlock) {
const blockContentUdi = blockEl.getAttribute('data-content-udi');
if(blockContentUdi && !blockEl.$block) {
const block = args.blockEditorApi.getBlockByContentUdi(blockContentUdi);
if(block) {
blockEl.removeAttribute('contenteditable');

if(block.config.displayInline && blockEl.nodeName.toLowerCase() === 'umb-rte-block') {
// Change element name:
const oldBlockEl = blockEl;
blockEl = document.createElement('umb-rte-block-inline');
blockEl.appendChild(document.createComment("Umbraco-Block"));
blockEl.setAttribute('data-content-udi', blockContentUdi);
oldBlockEl.parentNode.replaceChild(blockEl, oldBlockEl);
} else if(!block.config.displayInline && blockEl.nodeName.toLowerCase() === 'umb-rte-block-inline') {
// Change element name:
const oldBlockEl = blockEl;
blockEl = document.createElement('umb-rte-block');
blockEl.appendChild(document.createComment("Umbraco-Block"));
blockEl.setAttribute('data-content-udi', blockContentUdi);
oldBlockEl.parentNode.replaceChild(blockEl, oldBlockEl);
}

blockEl.$index = block.index;
blockEl.$block = block;
blockEl.$api = args.blockEditorApi;
blockEl.$culture = args.culture;
blockEl.$segment = args.segment;
blockEl.$parentForm = args.parentForm;
blockEl.$valFormManager = args.valFormManager;
$compile(blockEl)(args.scope);
blockEl.setAttribute('contenteditable', 'false');
//blockEl.setAttribute('draggable', 'true');

} else {
blockEl.removeAttribute('data-content-udi');
args.editor.dom.remove(blockEl);
}
} else {
args.editor.dom.remove(blockEl);
}
}
}
}

// If we can not find the insert image/media toolbar button
// Then we need to add an event listener to the editor
// That will update native browser drag & drop events
// To update the icon to show you can NOT drop something into the editor
if (args.toolbar && isMediaPickerEnabled(args.toolbar) === false) {
// Wire up the event listener
args.editor.on('dragstart dragend dragover draggesture dragdrop drop drag', function (e) {
e.preventDefault();
e.dataTransfer.effectAllowed = "none";
e.dataTransfer.dropEffect = "none";
e.stopPropagation();
});
}

args.editor.on('SetContent', function (e) {
var content = e.content;

// Upload BLOB images (dragged/pasted ones)
// find src attribute where value starts with `blob:`
// search is case-insensitive and allows single or double quotes
if (content.search(/src=["']blob:.*?["']/gi) !== -1) {
args.editor.uploadImages().then(function (data) {
// Once all images have been uploaded
data.forEach(function (item) {
// Skip items that failed upload
if (item.status === false) {
return;
}

// Select img element
var img = item.element;

// Get img src
var imgSrc = img.getAttribute("src");
var tmpLocation = localStorageService.get(`tinymce__${imgSrc}`)

// Select the img & add new attr which we can search for
// When its being persisted in RTE property editor
// To create a media item & delete this tmp one etc
args.editor.dom.setAttrib(img, "data-tmpimg", tmpLocation);

// Resize the image to the max size configured
// NOTE: no imagesrc passed into func as the src is blob://...
// We will append ImageResizing Querystrings on perist to DB with node save
sizeImageInEditor(args.editor, img);
});

// Get all img where src starts with blob: AND does NOT have a data=tmpimg attribute
// This is most likely seen as a duplicate image that has already been uploaded
// editor.uploadImages() does not give us any indiciation that the image been uploaded already
var blobImageWithNoTmpImgAttribute = args.editor.dom.select("img[src^='blob:']:not([data-tmpimg])");

//For each of these selected items
blobImageWithNoTmpImgAttribute.forEach(imageElement => {
var blobSrcUri = args.editor.dom.getAttrib(imageElement, "src");

// Find the same image uploaded (Should be in LocalStorage)
// May already exist in the editor as duplicate image
// OR added to the RTE, deleted & re-added again
// So lets fetch the tempurl out of localstorage for that blob URI item
var tmpLocation = localStorageService.get(`tinymce__${blobSrcUri}`)

if (tmpLocation) {
sizeImageInEditor(args.editor, imageElement);
args.editor.dom.setAttrib(imageElement, "data-tmpimg", tmpLocation);
}
});
});

}

if (Umbraco.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce === true) {
/** prevent injecting arbitrary JavaScript execution in on-attributes. */
const allNodes = Array.prototype.slice.call(args.editor.dom.doc.getElementsByTagName("*"));
allNodes.forEach(node => {
for (var i = 0; i < node.attributes.length; i++) {
if (node.attributes[i].name.indexOf("on") === 0) {
node.removeAttribute(node.attributes[i].name)
}
}
});
}

initBlocks();

});

args.editor.on('init', function () {

const currentValue = getPropertyValue();
if (currentValue) {
args.editor.setContent(currentValue);
}

//enable browser based spell checking
args.editor.getBody().setAttribute('spellcheck', true);

/** Setup sanitization for preventing injecting arbitrary JavaScript execution in attributes:
* https://github.com/advisories/GHSA-w7jx-j77m-wp65
* https://github.com/advisories/GHSA-5vm8-hhgr-jcjp
*/
const uriAttributesToSanitize = ['src', 'href', 'data', 'background', 'action', 'formaction', 'poster', 'xlink:href'];
const parseUri = function () {
// Encapsulated JS logic.
const safeSvgDataUrlElements = ['img', 'video'];
const scriptUriRegExp = /((java|vb)script|mhtml):/i;
const trimRegExp = /[\s\u0000-\u001F]+/g;
const isInvalidUri = (uri, tagName) => {
if (/^data:image\//i.test(uri)) {
return safeSvgDataUrlElements.indexOf(tagName) !== -1 && /^data:image\/svg\+xml/i.test(uri);
} else {
return /^data:/i.test(uri);
}
};

return function parseUri(uri, tagName) {
uri = uri.replace(trimRegExp, '');
try {
// Might throw malformed URI sequence
uri = decodeURIComponent(uri);
} catch (ex) {
// Fallback to non UTF-8 decoder
uri = unescape(uri);
}

if (scriptUriRegExp.test(uri)) {
return;
}

if (isInvalidUri(uri, tagName)) {
return;
}

return uri;
}
}();

if (Umbraco.Sys.ServerVariables.umbracoSettings.sanitizeTinyMce === true) {
args.editor.serializer.addAttributeFilter(uriAttributesToSanitize, function (nodes) {
nodes.forEach(function (node) {
node.attributes.forEach(function (attr) {
const attrName = attr.name.toLowerCase();
if (uriAttributesToSanitize.indexOf(attrName) !== -1) {
attr.value = parseUri(attr.value, node.name);
}
});
});
});
}

//start watching the value
startWatch();
});

args.editor.on('Change', function (e) {
syncContent();
});
args.editor.on('Keyup', function (e) {
syncContent();
});

//when we leave the editor (maybe)
args.editor.on('blur', function (e) {
syncContent();
});

// When the element is removed from the DOM, we need to terminate
// any active watchers to ensure scopes are disposed and do not leak.
// No need to sync content as that has already happened.
args.editor.on('remove', () => stopWatch());

args.editor.on('ObjectResized', function (e) {
var srcAttr = $(e.target).attr("src");

if (!srcAttr) {
return;
}

var path = srcAttr.split("?")[0];
mediaHelper.getProcessedImageUrl(path, {
width: e.width,
height: e.height,
mode: "max"
}).then(function (resizedPath) {
$(e.target).attr("data-mce-src", resizedPath);
});

syncContent();
});

args.editor.on('Dirty', function (e) {
syncContent(); // Set model.value to the RTE's content
});

let self = this;

//create link picker
self.createLinkPicker(args.editor, function (currentTarget, anchorElement) {

entityResource.getAnchors(getPropertyValue()).then(anchorValues => {

const linkPicker = {
currentTarget: currentTarget,
dataTypeKey: args.model.dataTypeKey,
ignoreUserStartNodes: args.model.config.ignoreUserStartNodes,
anchors: anchorValues,
size: args.model.config.overlaySize,
submit: model => {
self.insertLinkInEditor(args.editor, model.target, anchorElement);
editorService.close();
},
close: () => {
editorService.close();
}
};

editorService.linkPicker(linkPicker);
});

});

//Create the insert media plugin
self.createMediaPicker(args.editor, function (currentTarget, userData, imgDomElement) {

var startNodeId, startNodeIsVirtual;
if (!args.model.config.startNodeId) {
if (args.model.config.ignoreUserStartNodes === true) {
startNodeId = -1;
startNodeIsVirtual = true;
}
else {
startNodeId = userData.startMediaIds.length !== 1 ? -1 : userData.startMediaIds[0];
startNodeIsVirtual = userData.startMediaIds.length !== 1;
}
}

var mediaPicker = {
currentTarget: currentTarget,
onlyImages: true,
showDetails: true,
disableFolderSelect: true,
disableFocalPoint: true,
startNodeId: startNodeId,
startNodeIsVirtual: startNodeIsVirtual,
dataTypeKey: args.model.dataTypeKey,
submit: function (model) {
self.insertMediaInEditor(args.editor, model.selection[0], imgDomElement);
editorService.close();
},
close: function () {
editorService.close();
}
};
editorService.mediaPicker(mediaPicker);
});


//Create the insert block plugin
self.createBlockPicker(args.editor, args.blockEditorApi, function (currentTarget, userData, imgDomElement) {
args.blockEditorApi.showCreateDialog(0, false, (newBlock) => {
// TODO: Handle if its an array:
if(Utilities.isArray(newBlock)) {
newBlock.forEach(block => {
self.insertBlockInEditor(args.editor, block.layout.contentUdi, block.config.displayInline);
});
} else {
self.insertBlockInEditor(args.editor, newBlock.layout.contentUdi, newBlock.config.displayInline);
}
if(args.blockEditorApi) {
//Create the insert block plugin
self.createBlockPicker(args.editor, args.blockEditorApi, function (currentTarget, userData, imgDomElement) {
args.blockEditorApi.showCreateDialog(0, false, (newBlock) => {
// TODO: Handle if its an array:
if(Utilities.isArray(newBlock)) {
newBlock.forEach(block => {
self.insertBlockInEditor(args.editor, block.layout.contentUdi, block.config.displayInline);
});
} else {
self.insertBlockInEditor(args.editor, newBlock.layout.contentUdi, newBlock.config.displayInline);
}
});
});
});
}

Check warning on line 1764 in src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

❌ Getting worse: Complex Method

tinyMceService.initializeEditor increases in cyclomatic complexity from 48 to 50, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.

Check notice on line 1764 in src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

ℹ Getting worse: Bumpy Road Ahead

tinyMceService.initializeEditor increases from 5 to 6 logical blocks with deeply nested code, threshold is one single block per function. The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.

//Create the embedded plugin
self.createInsertEmbeddedMedia(args.editor, function (activeElement, modify) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
(function () {

Check notice on line 1 in src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

ℹ Getting worse: Overall Code Complexity

The mean cyclomatic complexity increases from 5.03 to 5.10, threshold = 4. This file has many conditional statements (e.g. if, for, while) across its implementation, leading to lower code health. Avoid adding more conditionals.
"use strict";

/**
Expand Down Expand Up @@ -43,6 +43,7 @@
var vm = this;

vm.readonly = false;
vm.noBlocksMode = false;
vm.tinyMceEditor = null;

$attrs.$observe('readonly', (value) => {
Expand Down Expand Up @@ -102,161 +103,171 @@
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";
//Could not find umbVariantContent in the $scope chain, lets go into no blocks mode:
vm.noBlocksMode = true;
vm.blocksLoading = false;
this.updateLoading();
}
}

const config = vm.model.config || {};

// set the onValueChanged callback, this will tell us if the block list 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;
liveEditing = config.useLiveEditing;

vm.listWrapperStyles = {};

if (vm.model.config.maxPropertyWidth) {
vm.listWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth;
if (config.maxPropertyWidth) {
vm.listWrapperStyles['max-width'] = config.maxPropertyWidth;
}

// We need to ensure that the property model value is an object, this is needed for modelObject to recive a reference and keep that updated.
// 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.
ensurePropertyValue(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();
}
const assetPromises = [];

/*
copyAllBlocksAction = {
labelKey: "clipboard_labelForCopyAllEntries",
labelTokens: [vm.model.label],
icon: "icon-documents",
method: requestCopyAllBlocks,
isDisabled: true,
useLegacyIcon: false
};

deleteAllBlocksAction = {
labelKey: "clipboard_labelForRemoveAllEntries",
labelTokens: [],
icon: "icon-trash",
method: requestDeleteAllBlocks,
isDisabled: true,
useLegacyIcon: false
};
if(vm.noBlocksMode !== true) {

var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction];
*/
var scopeOfExistence = $scope;
if (vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) {
scopeOfExistence = vm.umbVariantContentEditors.getScope();
} else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) {
scopeOfExistence = vm.umbElementEditorContent.getScope();
}

// Create Model Object, to manage our data for this Block Editor.
modelObject = blockEditorService.createModelObject(vm.model.value.blocks, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope);
const blockModelObjectLoading = modelObject.load()
blockModelObjectLoading.then(onLoaded);
/*
copyAllBlocksAction = {
labelKey: "clipboard_labelForCopyAllEntries",
labelTokens: [vm.model.label],
icon: "icon-documents",
method: requestCopyAllBlocks,
isDisabled: true,
useLegacyIcon: false
};

deleteAllBlocksAction = {
labelKey: "clipboard_labelForRemoveAllEntries",
labelTokens: [],
icon: "icon-trash",
method: requestDeleteAllBlocks,
isDisabled: true,
useLegacyIcon: false
};

var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction];
*/

// Create Model Object, to manage our data for this Block Editor.
modelObject = blockEditorService.createModelObject(vm.model.value.blocks, vm.model.editor, config.blocks, scopeOfExistence, $scope);
const blockModelObjectLoading = modelObject.load();
assetPromises.push(blockModelObjectLoading)
blockModelObjectLoading.then(onLoaded);
}


// ******************** //
// RTE PART:
// ******************** //


// To id the html textarea we need to use the datetime ticks because we can have multiple rte's per a single property alias
// because now we have to support having 2x (maybe more at some stage) content editors being displayed at once. This is because
// we have this mini content editor panel that can be launched with MNTP.
vm.textAreaHtmlId = vm.model.alias + "_" + String.CreateGuid();

var editorConfig = vm.model.config ? vm.model.config.editor : null;
var editorConfig = config.editor ?? null;
if (!editorConfig || Utilities.isString(editorConfig)) {
editorConfig = tinyMceService.defaultPrevalues();
}


var width = editorConfig.dimensions ? parseInt(editorConfig.dimensions.width, 10) || null : null;
var height = editorConfig.dimensions ? parseInt(editorConfig.dimensions.height, 10) || null : null;

vm.containerWidth = "auto";
vm.containerHeight = "auto";
vm.containerOverflow = "inherit";

const assetPromises = [blockModelObjectLoading];
vm.containerOverflow = "inherit"

//queue file loading
tinyMceAssets.forEach(function (tinyJsAsset) {
assetPromises.push(assetsService.loadJs(tinyJsAsset, $scope));
});

const tinyMceConfigDeferred = $q.defer();

//wait for assets to load before proceeding
$q.all(assetPromises).then(function () {

tinyMceService.getTinyMceEditorConfig({
htmlId: vm.textAreaHtmlId,
stylesheets: editorConfig.stylesheets,
toolbar: editorConfig.toolbar,
mode: editorConfig.mode
})
.then(function (tinyMceConfig) {
// Load the plugins.min.js file from the TinyMCE Cloud if a Cloud Api Key is specified
if (tinyMceConfig.cloudApiKey) {
return assetsService.loadJs(`https://cdn.tiny.cloud/1/${tinyMceConfig.cloudApiKey}/tinymce/${tinymce.majorVersion}.${tinymce.minorVersion}/plugins.min.js`)
.then(() => tinyMceConfig);
}

return tinyMceConfig;
})
.then(function (tinyMceConfig) {
tinyMceConfigDeferred.resolve(tinyMceConfig);
});
});

//wait for config to be ready after assets have loaded
tinyMceConfigDeferred.promise.then(function (standardConfig) {

if (height !== null) {
standardConfig.plugins.splice(standardConfig.plugins.indexOf("autoresize"), 1);
}

//create a baseline Config to extend upon
var baseLineConfigObj = {
maxImageSize: editorConfig.maxImageSize,
width: width,
height: height
};

baseLineConfigObj.setup = function (editor) {

//set the reference
vm.tinyMceEditor = editor;

vm.tinyMceEditor.on('init', function (e) {
$timeout(function () {
vm.rteLoading = false;
vm.updateLoading();
});
});
vm.tinyMceEditor.on("focus", function () {
$element[0].dispatchEvent(new CustomEvent('umb-rte-focus', {composed: true, bubbles: true}));
});
vm.tinyMceEditor.on("blur", function () {
$element[0].dispatchEvent(new CustomEvent('umb-rte-blur', {composed: true, bubbles: true}));
});

//initialize the standard editor functionality for Umbraco
tinyMceService.initializeEditor({
//scope: $scope,
editor: editor,
toolbar: editorConfig.toolbar,
model: vm.model,
getValue: function () {
return vm.model.value.markup;
},
setValue: function (newVal) {
vm.model.value.markup = newVal;
$scope.$evalAsync();
},
culture: vm.umbProperty?.culture ?? null,
segment: vm.umbProperty?.segment ?? null,
blockEditorApi: vm.blockEditorApi,
blockEditorApi: vm.noBlocksMode ? undefined : vm.blockEditorApi,

Check warning on line 270 in src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

❌ Getting worse: Complex Method

BlockRteController.onInit increases in cyclomatic complexity from 34 to 35, threshold = 9. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.

Check notice on line 270 in src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js

View check run for this annotation

CodeScene Delta Analysis / CodeScene Cloud Delta Analysis (release/13.1.0)

ℹ Getting worse: Bumpy Road Ahead

BlockRteController.onInit increases from 3 to 4 logical blocks with deeply nested code, threshold is one single block per function. The Bumpy Road code smell is a function that contains multiple chunks of nested conditional logic. The deeper the nesting and the more bumps, the lower the code health.
parentForm: vm.propertyForm,
valFormManager: vm.valFormManager,
currentFormInput: $scope.rteForm.modelValue
Expand Down Expand Up @@ -346,7 +357,9 @@

ensurePropertyValue(newVal);

modelObject.update(vm.model.value.blocks, $scope);
if(modelObject) {
modelObject.update(vm.model.value.blocks, $scope);
}
onLoaded();
}

Expand Down
Loading