Merge branch 'MDL-52724-master' of git://github.com/merrill-oakland/moodle

This commit is contained in:
Jake Dallimore 2021-05-03 13:25:05 +08:00
commit e5e5a06190
8 changed files with 919 additions and 17 deletions

View File

@ -51,6 +51,8 @@ class behat_form_editor extends behat_form_textarea {
$editorid = $this->field->getAttribute('id');
if ($this->running_javascript()) {
$value = addslashes($value);
// This will be transported in JSON, which doesn't allow newlines in strings, so we must escape them.
$value = str_replace("\n", "\\n", $value);
$js = '
(function() {
var editor = Y.one(document.getElementById("'.$editorid.'editable"));

View File

@ -0,0 +1,169 @@
@editor @editor_atto @atto @editor_moodleform
Feature: Atto HTML cleanup.
In order to test html cleaning functionality, I write in a HTML atto text field.
@javascript
Scenario: Extra UL close and orphan LI items
Given I log in as "admin"
When I open my profile in edit mode
And I click on "Show more buttons" "button"
And I click on "HTML" "button"
And I set the field "Description" to multiline:
"""
<li>A</li>
<li>B</li>
</ol>
<ul>
<li>C</li>
</ul></ul>
<li class="someclass ul UL">D</li>
<li>E</li>
"""
And I click on "HTML" "button"
Then the field "Description" matches multiline:
"""
<ol><li>A</li>
<li>B</li>
</ol>
<ul>
<li>C</li>
</ul>
<ul><li class="someclass ul UL">D</li>
<li>E</li></ul>
"""
@javascript
Scenario: Missing LI close tags, extra closing OL, missing closing UL tag
Given I log in as "admin"
When I open my profile in edit mode
And I click on "Show more buttons" "button"
And I click on "HTML" "button"
And I set the field "Description" to multiline:
"""
<div class="ol"><ol>
<li>A</li>
<li>B
</ol></div>
<ul>
<li>C
<li>D</li>
</ol>
"""
And I click on "HTML" "button"
Then the field "Description" matches multiline:
"""
<div class="ol"><ol>
<li>A</li>
<li>B
</li></ol></div>
<ul>
<li>C
</li><li>D</li></ul>
"""
@javascript
Scenario: Missing beginning OL tag, empty LI close tag
Given I log in as "admin"
When I open my profile in edit mode
And I click on "Show more buttons" "button"
And I click on "HTML" "button"
And I set the field "Description" to multiline:
"""
<p>Before</p>
<li>A</li></li>
<li>B</li>
</ol>
<p>After</p>
<ul data-info="UL ul OL ol">
<ul>
C</li>
<li>D</li>
<li>E
</ul>
</ul><ul>
<p>After 2</p>
"""
And I click on "HTML" "button"
Then the field "Description" matches multiline:
"""
<p>Before</p>
<ol><li>A</li>
<li>B</li>
</ol>
<p>After</p>
<ul data-info="UL ul OL ol">
<ul><li>
C</li>
<li>D</li>
<li>E
</li></ul>
</ul>
<p>After 2</p>
"""
@javascript
Scenario: Random close LI tag, extra LI open tag, missing OL tag
Given I log in as "admin"
When I open my profile in edit mode
And I click on "Show more buttons" "button"
And I click on "HTML" "button"
And I set the field "Description" to multiline:
"""
<p>Before</p></li><ul>
<ul>
<li>A</li>
B</li>
<li>C</li>
<ol>
<li>D</li>
<li>E
<p>After</p>
"""
And I click on "HTML" "button"
Then the field "Description" matches multiline:
"""
<p>Before</p>
<ul>
<li>A</li><li>
B</li>
<li>C</li></ul>
<ol>
<li>D</li></ol>
E
<p>After</p>
"""
@javascript
Scenario: Missing opening LI tags, missing closing UL tag
Given I log in as "admin"
When I open my profile in edit mode
And I click on "Show more buttons" "button"
And I click on "HTML" "button"
And I set the field "Description" to multiline:
"""
<li>Before</li>
<ul>
<li>A</li>
B</li>
<ol>
1</li>
</ol>
<li>C
<li>D</li>
<p>After</p>
"""
And I click on "HTML" "button"
Then the field "Description" matches multiline:
"""
<ul><li>Before</li></ul>
<ul>
<li>A</li><li>
B</li>
<ol><li>
1</li>
</ol>
<li>C
</li><li>D</li></ul>
<p>After</p>
"""

View File

@ -718,7 +718,7 @@ EditorTextArea.prototype = {
this.editor.setHTML('');
// Copy cleaned HTML to editable div.
this.editor.append(this._cleanHTML(this.textarea.get('value')));
this.editor.append(this._cleanHTML(this.textarea.get('value'), true));
// Insert a paragraph in the empty contenteditable div.
if (this.editor.getHTML() === '') {
@ -1393,9 +1393,10 @@ EditorClean.prototype = {
* @method _cleanHTML
* @private
* @param {String} content The content to clean
* @param {Boolean} deepClean If true, do a more in depth (and resource intensive) cleaning of the HTML.
* @return {String} The cleaned HTML
*/
_cleanHTML: function(content) {
_cleanHTML: function(content, deepClean) {
// Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
var rules = [
@ -1412,7 +1413,13 @@ EditorClean.prototype = {
{regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
];
return this._filterContentWithRules(content, rules);
content = this._filterContentWithRules(content, rules);
if (deepClean) {
content = this._cleanHTMLLists(content);
}
return content;
},
/**
@ -1443,6 +1450,8 @@ EditorClean.prototype = {
pasteCleanup: function(sourceEvent) {
// We only expect paste events, but we will check anyways.
if (sourceEvent.type === 'paste') {
// Register the delayed paste cleanup. We will cancel it if we register the fallback cleanup.
var delayedCleanup = this.postPasteCleanupDelayed();
// The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
var event = sourceEvent._event;
// Check if we have a valid clipboardData object in the event.
@ -1465,6 +1474,7 @@ EditorClean.prototype = {
content = event.clipboardData.getData('text/html');
} catch (error) {
// Something went wrong. Fallback.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -1497,6 +1507,7 @@ EditorClean.prototype = {
// Something went wrong. Fallback.
// Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
// Wait for the clipboard event to finish then fallback clean the entire editor.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -1515,10 +1526,50 @@ EditorClean.prototype = {
return true;
},
/**
* Calls postPasteCleanup on a short timer to allow the paste event handlers to complete, then deep clean the content.
*
* @method postPasteCleanupDelayed
* @return {object}
* @chainable
*/
postPasteCleanupDelayed: function() {
Y.soon(Y.bind(this.postPasteCleanup, this));
return this;
},
/**
* Do additional cleanup after the paste is complete.
*
* @method postPasteCleanup
* @return {object}
* @chainable
*/
postPasteCleanup: function() {
Y.log('Executing delayed post paste cleanup', 'debug', LOGNAME);
// Save the current selection (cursor position).
var selection = window.rangy.saveSelection();
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(content, true));
// Update the textarea.
this.updateOriginal();
// Restore the selection (cursor position).
window.rangy.restoreSelection(selection);
return this;
},
/**
* Cleanup code after a paste event if we couldn't intercept the paste content.
*
* @method fallbackPasteCleanup
* @return {object}
* @chainable
*/
fallbackPasteCleanup: function() {
@ -1529,7 +1580,7 @@ EditorClean.prototype = {
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanPasteHTML(content));
this.editor.set('innerHTML', this._cleanHTML(this._cleanPasteHTML(content), true));
// Update the textarea.
this.updateOriginal();
@ -1709,6 +1760,198 @@ EditorClean.prototype = {
});
return holder.innerHTML;
},
/**
* This is a function that searches for, and attempts to correct certain issues with ul/ol html lists.
* This is needed because these lists are used heavily in page layout, and content with bad tags can
* lead to broke course pages.
*
* The theory of operation here is to linearly process the incoming content, counting the opening and closing
* of list tags, and determining when there is a mismatch.
*
* The specific issues this should be able to correct are:
* - Orphaned li elements will be wrapped in a set of ul tags.
* - li elements inside li elements.
* - An extra closing ul, or ol tag will be discarded.
* - An extra closing li tag will have an opening tag added if appropriate, or will be discarded.
* - If there is an unmatched list open tag, a matching close tag will be inserted.
*
* It does it's best to match the case of corrected tags. Even though not required by html spec,
* it seems like the safer route.
*
* A note on parent elements of li. This code assumes that li must have a ol or ul parent.
* There are two other potential other parents of li. They are menu and dir. The dir tag was deprecated in
* HTML4, and removed in HTML5. The menu tag is experimental as of this writing, and basically doesn't work
* in any browsers, even Firefox, which theoretically has limited support for it. If other parents of li
* become viable, they will need to be added to this code.
*
* @method _cleanHTMLLists
* @private
* @param {String} content The content to clean
* @return {String} The cleaned content
*/
_cleanHTMLLists: function(content) {
var output = '',
toProcess = content,
match = null,
openTags = [],
currentTag = null,
previousTag = null;
// Use a regular expression to find the next open or close li, ul, or ol tag.
// Keep going until there are no more matching tags left.
while ((match = toProcess.match(/<(\/?)(li|ul|ol)[^>]*>/i))) {
currentTag = {
tag: match[2],
tagLowerCase: match[2].toLowerCase(),
fullTag: match[0],
isOpen: (match[1].length == 1) ? false : true
};
// Get the most recent open tag.
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
// Slice up the content based on the match and add content before the match to output.
output += toProcess.slice(0, match.index);
toProcess = toProcess.slice(match.index + match[0].length);
// Now the full content is in output + currentTag.fullTag + toProcess. When making fixes, it is best to push the fix and
// fullTag back onto the front or toProcess, then restart the loop. This allows processing to follow the normal path
// most often. But sometimes we will need to modify output to insert or remove tags in the already complete code.
if (currentTag.isOpen) {
// We are at the opening phase of a tag.
// We have to do special processing for list items, as they can only be children of ul and ol tags.
if (currentTag.tagLowerCase === 'li') {
if (!previousTag) {
// This means we have are opening a li, but aren't in a list. This is not allowed!
// We are going to check for the count of open and close ol tags ahead to decide what to do.
var closeCount = (toProcess.match(/<\/(ol)[ >]/ig) || []).length;
var openCount = (toProcess.match(/<(ol)[ >]/ig) || []).length;
if (closeCount > openCount) {
// There are more close ol's ahead than opens ahead. So open the ol and try again.
Y.log('Adding an opening ol for orphan li', 'debug', LOGNAME);
toProcess = '<ol>' + currentTag.fullTag + toProcess;
continue;
}
// For the other cases, just open a ul and try again. Later the closing ul will get matched if it exists,
// or if it doesn't one will automatically get inserted.
Y.log('Adding an opening ul for orphan li', 'debug', LOGNAME);
toProcess = '<ul>' + currentTag.fullTag + toProcess;
continue;
}
if (previousTag.tagLowerCase === 'li') {
// You aren't allowed to nest li tags. Close the current one before starting the new one.
Y.log('Adding a closing ' + previousTag.tag + ' before opening a new one.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
}
// Previous tag must be a list at this point, so we can continue.
}
// If we made it this far, record the tag to the open tags list.
openTags.push({
tag: currentTag.tag,
tagLowerCase: currentTag.tagLowerCase,
position: output.length,
length: currentTag.fullTag.length
});
} else {
// We are processing a closing tag.
if (openTags.length == 0) {
// We are closing a tag that isn't open. That's a problem. Just discarding should be safe.
Y.log('Discarding extra ' + currentTag.fullTag + ' tag.', 'debug', LOGNAME);
continue;
}
if (previousTag.tagLowerCase === currentTag.tagLowerCase) {
// Closing a tag that matches the open tag. This is the nominal case. Pop it off, and update previousTag.
if (currentTag.tag != previousTag.tag) {
// This would mean cases don't match between the opening and closing tag.
// We are going to swap them to match, even though not required.
currentTag.fullTag = currentTag.fullTag.replace(currentTag.tag, previousTag.tag);
}
openTags.pop();
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
} else {
// We are closing a tag that isn't the most recent open one open, so we have a mismatch.
if (currentTag.tagLowerCase === 'li' && previousTag.liEnd && (previousTag.liEnd < output.length)) {
// We are closing an unopened li, but the parent list has complete li tags more than 0 chars ago.
// Assume we are missing an open li at the end of the previous li, and insert there.
Y.log('Inserting opening ' + currentTag.tag + ' after previous li.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.liEnd);
} else if (currentTag.tagLowerCase === 'li' && !previousTag.liEnd &&
((previousTag.position + previousTag.length) < output.length)) {
// We are closing an unopened li, and the parent has no previous lis in it, but opened more than 0
// chars ago. Assume we are missing a starting li, and insert it right after the list opened.
Y.log('Inserting opening ' + currentTag.tag + ' at start of parent.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.position + previousTag.length);
} else if (previousTag.tagLowerCase === 'li') {
// We must be trying to close a ul/ol while in a li. Just assume we are missing a closing li.
Y.log('Adding a closing ' + previousTag.tag + ' before closing ' + currentTag.tag + '.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
} else {
// Here we must be trying to close a tag that isn't open, or is open higher up. Just discard.
// If there ends up being a missing close tag later on, that will get fixed separately.
Y.log('Discarding incorrect ' + currentTag.fullTag + '.', 'debug', LOGNAME);
continue;
}
}
// If we have a valid closing li tag, and a list, record where the li ended.
if (currentTag.tagLowerCase === 'li' && previousTag) {
previousTag.liEnd = output.length + currentTag.fullTag.length;
}
}
// Now we can add the tag to the output.
output += currentTag.fullTag;
}
// Add anything left in toProcess to the output.
output += toProcess;
// Anything still in the openTags list are extra and need to be dealt with.
if (openTags.length) {
// Work on the list in reverse order so positions stay correct.
while ((currentTag = openTags.pop())) {
if (currentTag.liEnd) {
// We have a position for the last list item in this element. Insert the closing it after that.
output = this._insertString(output, '</' + currentTag.tag + '>', currentTag.liEnd);
Y.log('Adding closing ' + currentTag.tag + ' based on last li location.', 'debug', LOGNAME);
} else {
// If there weren't any children list items, then we should just remove the tag where it started.
// This will also remote an open li tag that runs to the end of the content, since it has no children lis.
output = output.slice(0, currentTag.position) + output.slice(currentTag.position + currentTag.length);
Y.log('Removing opening ' + currentTag.fullTag + ' because it was missing closing.', 'debug', LOGNAME);
}
}
}
return output;
},
/**
* Insert a string in the middle of an existing string at the specified location.
*
* @method _insertString
* @param {String} content The subject of the insertion.
* @param {String} insert The string that will be inserted.
* @param {Number} position The location to make the insertion.
* @return {String} The string with the new content inserted.
*/
_insertString: function(content, insert, position) {
return content.slice(0, position) + insert + content.slice(position);
}
};

File diff suppressed because one or more lines are too long

View File

@ -713,7 +713,7 @@ EditorTextArea.prototype = {
this.editor.setHTML('');
// Copy cleaned HTML to editable div.
this.editor.append(this._cleanHTML(this.textarea.get('value')));
this.editor.append(this._cleanHTML(this.textarea.get('value'), true));
// Insert a paragraph in the empty contenteditable div.
if (this.editor.getHTML() === '') {
@ -1382,9 +1382,10 @@ EditorClean.prototype = {
* @method _cleanHTML
* @private
* @param {String} content The content to clean
* @param {Boolean} deepClean If true, do a more in depth (and resource intensive) cleaning of the HTML.
* @return {String} The cleaned HTML
*/
_cleanHTML: function(content) {
_cleanHTML: function(content, deepClean) {
// Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
var rules = [
@ -1401,7 +1402,13 @@ EditorClean.prototype = {
{regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
];
return this._filterContentWithRules(content, rules);
content = this._filterContentWithRules(content, rules);
if (deepClean) {
content = this._cleanHTMLLists(content);
}
return content;
},
/**
@ -1432,6 +1439,8 @@ EditorClean.prototype = {
pasteCleanup: function(sourceEvent) {
// We only expect paste events, but we will check anyways.
if (sourceEvent.type === 'paste') {
// Register the delayed paste cleanup. We will cancel it if we register the fallback cleanup.
var delayedCleanup = this.postPasteCleanupDelayed();
// The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
var event = sourceEvent._event;
// Check if we have a valid clipboardData object in the event.
@ -1454,6 +1463,7 @@ EditorClean.prototype = {
content = event.clipboardData.getData('text/html');
} catch (error) {
// Something went wrong. Fallback.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -1486,6 +1496,7 @@ EditorClean.prototype = {
// Something went wrong. Fallback.
// Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
// Wait for the clipboard event to finish then fallback clean the entire editor.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -1504,10 +1515,49 @@ EditorClean.prototype = {
return true;
},
/**
* Calls postPasteCleanup on a short timer to allow the paste event handlers to complete, then deep clean the content.
*
* @method postPasteCleanupDelayed
* @return {object}
* @chainable
*/
postPasteCleanupDelayed: function() {
Y.soon(Y.bind(this.postPasteCleanup, this));
return this;
},
/**
* Do additional cleanup after the paste is complete.
*
* @method postPasteCleanup
* @return {object}
* @chainable
*/
postPasteCleanup: function() {
// Save the current selection (cursor position).
var selection = window.rangy.saveSelection();
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(content, true));
// Update the textarea.
this.updateOriginal();
// Restore the selection (cursor position).
window.rangy.restoreSelection(selection);
return this;
},
/**
* Cleanup code after a paste event if we couldn't intercept the paste content.
*
* @method fallbackPasteCleanup
* @return {object}
* @chainable
*/
fallbackPasteCleanup: function() {
@ -1517,7 +1567,7 @@ EditorClean.prototype = {
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanPasteHTML(content));
this.editor.set('innerHTML', this._cleanHTML(this._cleanPasteHTML(content), true));
// Update the textarea.
this.updateOriginal();
@ -1697,6 +1747,188 @@ EditorClean.prototype = {
});
return holder.innerHTML;
},
/**
* This is a function that searches for, and attempts to correct certain issues with ul/ol html lists.
* This is needed because these lists are used heavily in page layout, and content with bad tags can
* lead to broke course pages.
*
* The theory of operation here is to linearly process the incoming content, counting the opening and closing
* of list tags, and determining when there is a mismatch.
*
* The specific issues this should be able to correct are:
* - Orphaned li elements will be wrapped in a set of ul tags.
* - li elements inside li elements.
* - An extra closing ul, or ol tag will be discarded.
* - An extra closing li tag will have an opening tag added if appropriate, or will be discarded.
* - If there is an unmatched list open tag, a matching close tag will be inserted.
*
* It does it's best to match the case of corrected tags. Even though not required by html spec,
* it seems like the safer route.
*
* A note on parent elements of li. This code assumes that li must have a ol or ul parent.
* There are two other potential other parents of li. They are menu and dir. The dir tag was deprecated in
* HTML4, and removed in HTML5. The menu tag is experimental as of this writing, and basically doesn't work
* in any browsers, even Firefox, which theoretically has limited support for it. If other parents of li
* become viable, they will need to be added to this code.
*
* @method _cleanHTMLLists
* @private
* @param {String} content The content to clean
* @return {String} The cleaned content
*/
_cleanHTMLLists: function(content) {
var output = '',
toProcess = content,
match = null,
openTags = [],
currentTag = null,
previousTag = null;
// Use a regular expression to find the next open or close li, ul, or ol tag.
// Keep going until there are no more matching tags left.
while ((match = toProcess.match(/<(\/?)(li|ul|ol)[^>]*>/i))) {
currentTag = {
tag: match[2],
tagLowerCase: match[2].toLowerCase(),
fullTag: match[0],
isOpen: (match[1].length == 1) ? false : true
};
// Get the most recent open tag.
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
// Slice up the content based on the match and add content before the match to output.
output += toProcess.slice(0, match.index);
toProcess = toProcess.slice(match.index + match[0].length);
// Now the full content is in output + currentTag.fullTag + toProcess. When making fixes, it is best to push the fix and
// fullTag back onto the front or toProcess, then restart the loop. This allows processing to follow the normal path
// most often. But sometimes we will need to modify output to insert or remove tags in the already complete code.
if (currentTag.isOpen) {
// We are at the opening phase of a tag.
// We have to do special processing for list items, as they can only be children of ul and ol tags.
if (currentTag.tagLowerCase === 'li') {
if (!previousTag) {
// This means we have are opening a li, but aren't in a list. This is not allowed!
// We are going to check for the count of open and close ol tags ahead to decide what to do.
var closeCount = (toProcess.match(/<\/(ol)[ >]/ig) || []).length;
var openCount = (toProcess.match(/<(ol)[ >]/ig) || []).length;
if (closeCount > openCount) {
// There are more close ol's ahead than opens ahead. So open the ol and try again.
toProcess = '<ol>' + currentTag.fullTag + toProcess;
continue;
}
// For the other cases, just open a ul and try again. Later the closing ul will get matched if it exists,
// or if it doesn't one will automatically get inserted.
toProcess = '<ul>' + currentTag.fullTag + toProcess;
continue;
}
if (previousTag.tagLowerCase === 'li') {
// You aren't allowed to nest li tags. Close the current one before starting the new one.
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
}
// Previous tag must be a list at this point, so we can continue.
}
// If we made it this far, record the tag to the open tags list.
openTags.push({
tag: currentTag.tag,
tagLowerCase: currentTag.tagLowerCase,
position: output.length,
length: currentTag.fullTag.length
});
} else {
// We are processing a closing tag.
if (openTags.length == 0) {
// We are closing a tag that isn't open. That's a problem. Just discarding should be safe.
continue;
}
if (previousTag.tagLowerCase === currentTag.tagLowerCase) {
// Closing a tag that matches the open tag. This is the nominal case. Pop it off, and update previousTag.
if (currentTag.tag != previousTag.tag) {
// This would mean cases don't match between the opening and closing tag.
// We are going to swap them to match, even though not required.
currentTag.fullTag = currentTag.fullTag.replace(currentTag.tag, previousTag.tag);
}
openTags.pop();
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
} else {
// We are closing a tag that isn't the most recent open one open, so we have a mismatch.
if (currentTag.tagLowerCase === 'li' && previousTag.liEnd && (previousTag.liEnd < output.length)) {
// We are closing an unopened li, but the parent list has complete li tags more than 0 chars ago.
// Assume we are missing an open li at the end of the previous li, and insert there.
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.liEnd);
} else if (currentTag.tagLowerCase === 'li' && !previousTag.liEnd &&
((previousTag.position + previousTag.length) < output.length)) {
// We are closing an unopened li, and the parent has no previous lis in it, but opened more than 0
// chars ago. Assume we are missing a starting li, and insert it right after the list opened.
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.position + previousTag.length);
} else if (previousTag.tagLowerCase === 'li') {
// We must be trying to close a ul/ol while in a li. Just assume we are missing a closing li.
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
} else {
// Here we must be trying to close a tag that isn't open, or is open higher up. Just discard.
// If there ends up being a missing close tag later on, that will get fixed separately.
continue;
}
}
// If we have a valid closing li tag, and a list, record where the li ended.
if (currentTag.tagLowerCase === 'li' && previousTag) {
previousTag.liEnd = output.length + currentTag.fullTag.length;
}
}
// Now we can add the tag to the output.
output += currentTag.fullTag;
}
// Add anything left in toProcess to the output.
output += toProcess;
// Anything still in the openTags list are extra and need to be dealt with.
if (openTags.length) {
// Work on the list in reverse order so positions stay correct.
while ((currentTag = openTags.pop())) {
if (currentTag.liEnd) {
// We have a position for the last list item in this element. Insert the closing it after that.
output = this._insertString(output, '</' + currentTag.tag + '>', currentTag.liEnd);
} else {
// If there weren't any children list items, then we should just remove the tag where it started.
// This will also remote an open li tag that runs to the end of the content, since it has no children lis.
output = output.slice(0, currentTag.position) + output.slice(currentTag.position + currentTag.length);
}
}
}
return output;
},
/**
* Insert a string in the middle of an existing string at the specified location.
*
* @method _insertString
* @param {String} content The subject of the insertion.
* @param {String} insert The string that will be inserted.
* @param {Number} position The location to make the insertion.
* @return {String} The string with the new content inserted.
*/
_insertString: function(content, insert, position) {
return content.slice(0, position) + insert + content.slice(position);
}
};

View File

@ -98,9 +98,10 @@ EditorClean.prototype = {
* @method _cleanHTML
* @private
* @param {String} content The content to clean
* @param {Boolean} deepClean If true, do a more in depth (and resource intensive) cleaning of the HTML.
* @return {String} The cleaned HTML
*/
_cleanHTML: function(content) {
_cleanHTML: function(content, deepClean) {
// Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
var rules = [
@ -117,7 +118,13 @@ EditorClean.prototype = {
{regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
];
return this._filterContentWithRules(content, rules);
content = this._filterContentWithRules(content, rules);
if (deepClean) {
content = this._cleanHTMLLists(content);
}
return content;
},
/**
@ -148,6 +155,8 @@ EditorClean.prototype = {
pasteCleanup: function(sourceEvent) {
// We only expect paste events, but we will check anyways.
if (sourceEvent.type === 'paste') {
// Register the delayed paste cleanup. We will cancel it if we register the fallback cleanup.
var delayedCleanup = this.postPasteCleanupDelayed();
// The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
var event = sourceEvent._event;
// Check if we have a valid clipboardData object in the event.
@ -170,6 +179,7 @@ EditorClean.prototype = {
content = event.clipboardData.getData('text/html');
} catch (error) {
// Something went wrong. Fallback.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -202,6 +212,7 @@ EditorClean.prototype = {
// Something went wrong. Fallback.
// Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
// Wait for the clipboard event to finish then fallback clean the entire editor.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
@ -220,10 +231,50 @@ EditorClean.prototype = {
return true;
},
/**
* Calls postPasteCleanup on a short timer to allow the paste event handlers to complete, then deep clean the content.
*
* @method postPasteCleanupDelayed
* @return {object}
* @chainable
*/
postPasteCleanupDelayed: function() {
Y.soon(Y.bind(this.postPasteCleanup, this));
return this;
},
/**
* Do additional cleanup after the paste is complete.
*
* @method postPasteCleanup
* @return {object}
* @chainable
*/
postPasteCleanup: function() {
Y.log('Executing delayed post paste cleanup', 'debug', LOGNAME);
// Save the current selection (cursor position).
var selection = window.rangy.saveSelection();
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(content, true));
// Update the textarea.
this.updateOriginal();
// Restore the selection (cursor position).
window.rangy.restoreSelection(selection);
return this;
},
/**
* Cleanup code after a paste event if we couldn't intercept the paste content.
*
* @method fallbackPasteCleanup
* @return {object}
* @chainable
*/
fallbackPasteCleanup: function() {
@ -234,7 +285,7 @@ EditorClean.prototype = {
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanPasteHTML(content));
this.editor.set('innerHTML', this._cleanHTML(this._cleanPasteHTML(content), true));
// Update the textarea.
this.updateOriginal();
@ -414,6 +465,198 @@ EditorClean.prototype = {
});
return holder.innerHTML;
},
/**
* This is a function that searches for, and attempts to correct certain issues with ul/ol html lists.
* This is needed because these lists are used heavily in page layout, and content with bad tags can
* lead to broke course pages.
*
* The theory of operation here is to linearly process the incoming content, counting the opening and closing
* of list tags, and determining when there is a mismatch.
*
* The specific issues this should be able to correct are:
* - Orphaned li elements will be wrapped in a set of ul tags.
* - li elements inside li elements.
* - An extra closing ul, or ol tag will be discarded.
* - An extra closing li tag will have an opening tag added if appropriate, or will be discarded.
* - If there is an unmatched list open tag, a matching close tag will be inserted.
*
* It does it's best to match the case of corrected tags. Even though not required by html spec,
* it seems like the safer route.
*
* A note on parent elements of li. This code assumes that li must have a ol or ul parent.
* There are two other potential other parents of li. They are menu and dir. The dir tag was deprecated in
* HTML4, and removed in HTML5. The menu tag is experimental as of this writing, and basically doesn't work
* in any browsers, even Firefox, which theoretically has limited support for it. If other parents of li
* become viable, they will need to be added to this code.
*
* @method _cleanHTMLLists
* @private
* @param {String} content The content to clean
* @return {String} The cleaned content
*/
_cleanHTMLLists: function(content) {
var output = '',
toProcess = content,
match = null,
openTags = [],
currentTag = null,
previousTag = null;
// Use a regular expression to find the next open or close li, ul, or ol tag.
// Keep going until there are no more matching tags left.
while ((match = toProcess.match(/<(\/?)(li|ul|ol)[^>]*>/i))) {
currentTag = {
tag: match[2],
tagLowerCase: match[2].toLowerCase(),
fullTag: match[0],
isOpen: (match[1].length == 1) ? false : true
};
// Get the most recent open tag.
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
// Slice up the content based on the match and add content before the match to output.
output += toProcess.slice(0, match.index);
toProcess = toProcess.slice(match.index + match[0].length);
// Now the full content is in output + currentTag.fullTag + toProcess. When making fixes, it is best to push the fix and
// fullTag back onto the front or toProcess, then restart the loop. This allows processing to follow the normal path
// most often. But sometimes we will need to modify output to insert or remove tags in the already complete code.
if (currentTag.isOpen) {
// We are at the opening phase of a tag.
// We have to do special processing for list items, as they can only be children of ul and ol tags.
if (currentTag.tagLowerCase === 'li') {
if (!previousTag) {
// This means we have are opening a li, but aren't in a list. This is not allowed!
// We are going to check for the count of open and close ol tags ahead to decide what to do.
var closeCount = (toProcess.match(/<\/(ol)[ >]/ig) || []).length;
var openCount = (toProcess.match(/<(ol)[ >]/ig) || []).length;
if (closeCount > openCount) {
// There are more close ol's ahead than opens ahead. So open the ol and try again.
Y.log('Adding an opening ol for orphan li', 'debug', LOGNAME);
toProcess = '<ol>' + currentTag.fullTag + toProcess;
continue;
}
// For the other cases, just open a ul and try again. Later the closing ul will get matched if it exists,
// or if it doesn't one will automatically get inserted.
Y.log('Adding an opening ul for orphan li', 'debug', LOGNAME);
toProcess = '<ul>' + currentTag.fullTag + toProcess;
continue;
}
if (previousTag.tagLowerCase === 'li') {
// You aren't allowed to nest li tags. Close the current one before starting the new one.
Y.log('Adding a closing ' + previousTag.tag + ' before opening a new one.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
}
// Previous tag must be a list at this point, so we can continue.
}
// If we made it this far, record the tag to the open tags list.
openTags.push({
tag: currentTag.tag,
tagLowerCase: currentTag.tagLowerCase,
position: output.length,
length: currentTag.fullTag.length
});
} else {
// We are processing a closing tag.
if (openTags.length == 0) {
// We are closing a tag that isn't open. That's a problem. Just discarding should be safe.
Y.log('Discarding extra ' + currentTag.fullTag + ' tag.', 'debug', LOGNAME);
continue;
}
if (previousTag.tagLowerCase === currentTag.tagLowerCase) {
// Closing a tag that matches the open tag. This is the nominal case. Pop it off, and update previousTag.
if (currentTag.tag != previousTag.tag) {
// This would mean cases don't match between the opening and closing tag.
// We are going to swap them to match, even though not required.
currentTag.fullTag = currentTag.fullTag.replace(currentTag.tag, previousTag.tag);
}
openTags.pop();
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
} else {
// We are closing a tag that isn't the most recent open one open, so we have a mismatch.
if (currentTag.tagLowerCase === 'li' && previousTag.liEnd && (previousTag.liEnd < output.length)) {
// We are closing an unopened li, but the parent list has complete li tags more than 0 chars ago.
// Assume we are missing an open li at the end of the previous li, and insert there.
Y.log('Inserting opening ' + currentTag.tag + ' after previous li.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.liEnd);
} else if (currentTag.tagLowerCase === 'li' && !previousTag.liEnd &&
((previousTag.position + previousTag.length) < output.length)) {
// We are closing an unopened li, and the parent has no previous lis in it, but opened more than 0
// chars ago. Assume we are missing a starting li, and insert it right after the list opened.
Y.log('Inserting opening ' + currentTag.tag + ' at start of parent.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.position + previousTag.length);
} else if (previousTag.tagLowerCase === 'li') {
// We must be trying to close a ul/ol while in a li. Just assume we are missing a closing li.
Y.log('Adding a closing ' + previousTag.tag + ' before closing ' + currentTag.tag + '.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
} else {
// Here we must be trying to close a tag that isn't open, or is open higher up. Just discard.
// If there ends up being a missing close tag later on, that will get fixed separately.
Y.log('Discarding incorrect ' + currentTag.fullTag + '.', 'debug', LOGNAME);
continue;
}
}
// If we have a valid closing li tag, and a list, record where the li ended.
if (currentTag.tagLowerCase === 'li' && previousTag) {
previousTag.liEnd = output.length + currentTag.fullTag.length;
}
}
// Now we can add the tag to the output.
output += currentTag.fullTag;
}
// Add anything left in toProcess to the output.
output += toProcess;
// Anything still in the openTags list are extra and need to be dealt with.
if (openTags.length) {
// Work on the list in reverse order so positions stay correct.
while ((currentTag = openTags.pop())) {
if (currentTag.liEnd) {
// We have a position for the last list item in this element. Insert the closing it after that.
output = this._insertString(output, '</' + currentTag.tag + '>', currentTag.liEnd);
Y.log('Adding closing ' + currentTag.tag + ' based on last li location.', 'debug', LOGNAME);
} else {
// If there weren't any children list items, then we should just remove the tag where it started.
// This will also remote an open li tag that runs to the end of the content, since it has no children lis.
output = output.slice(0, currentTag.position) + output.slice(currentTag.position + currentTag.length);
Y.log('Removing opening ' + currentTag.fullTag + ' because it was missing closing.', 'debug', LOGNAME);
}
}
}
return output;
},
/**
* Insert a string in the middle of an existing string at the specified location.
*
* @method _insertString
* @param {String} content The subject of the insertion.
* @param {String} insert The string that will be inserted.
* @param {Number} position The location to make the insertion.
* @return {String} The string with the new content inserted.
*/
_insertString: function(content, insert, position) {
return content.slice(0, position) + insert + content.slice(position);
}
};

View File

@ -71,7 +71,7 @@ EditorTextArea.prototype = {
this.editor.setHTML('');
// Copy cleaned HTML to editable div.
this.editor.append(this._cleanHTML(this.textarea.get('value')));
this.editor.append(this._cleanHTML(this.textarea.get('value'), true));
// Insert a paragraph in the empty contenteditable div.
if (this.editor.getHTML() === '') {

View File

@ -314,6 +314,19 @@ class behat_forms extends behat_base {
}
}
/**
* Checks, the field matches the value.
*
* @Then /^the field "(?P<field_string>(?:[^"]|\\")*)" matches multiline:$/
* @throws ElementNotFoundException Thrown by behat_base::find
* @param string $field
* @param PyStringNode $value
* @return void
*/
public function the_field_matches_multiline($field, PyStringNode $value) {
$this->the_field_matches_value($field, (string)$value);
}
/**
* Checks, the field does not match the value.
*