FEATURE: Updated HtmlEditorField to use DOMDocument to more reliably parse image tracking and shortcode link tracking data.

From: Andrew Short <andrewjshort@gmail.com>

git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/trunk@88492 467b73ca-7a2a-4603-9d3b-597d59a354a9
This commit is contained in:
Andrew Short 2009-10-11 00:07:13 +00:00 committed by Sam Minnee
parent 73b0ea643f
commit 1859ba502b
3 changed files with 240 additions and 207 deletions

View File

@ -1,203 +1,142 @@
<?php
/**
* A WYSIWYG editor field, powered by tinymce.
* tinymce editor fields are created from <textarea> tags which are then converted with javascript.
* The {@link Requirements} system is used to ensure that all necessary javascript is included.
* Caution: Only works within the CMS with a global tinymce-menubar, see {@link CMSMain}
*
*
* A TinyMCE-powered WYSIWYG HTML editor field with image and link insertion and tracking capabilities. Editor fields
* are created from <textarea> tags, which are then converted with JavaScript.
*
* @package forms
* @subpackage fields-formattedinput
*/
class HtmlEditorField extends TextareaField {
/**
* Includes the javascript neccesary for this field to work in the current output.
* NOTE: If you are loading a form that includes an HtmlEditorField via Ajax this function must be called in the requesting page, because
* javascript is not sent via ajax
* Includes the JavaScript neccesary for this field to work using the {@link Requirements} system.
*/
static function include_js() {
Requirements::javascript(MCE_ROOT . "tiny_mce_src.js");
public static function include_js() {
Requirements::javascript(MCE_ROOT . 'tiny_mce_src.js');
Requirements::customScript(HtmlEditorConfig::get_active()->generateJS(), 'htmlEditorConfig');
}
/**
* Construct a new HtmlEditor field
* @see TextareaField::__construct()
*/
function __construct($name, $title = null, $rows = 30, $cols = 20, $value = "", $form = null) {
public function __construct($name, $title = null, $rows = 30, $cols = 20, $value = '', $form = null) {
parent::__construct($name, $title, $rows, $cols, $value, $form);
$this->extraClass = 'typography';
$this->addExtraClass('typography');
$this->addExtraClass('htmleditor');
self::include_js();
}
/**
* Returns the a <textarea> field with tinymce="true" set on it
* @return string
*/
function Field() {
// Make sure the nessecary javascript is included
self::include_js();
// Don't allow unclosed tags - they will break the whole application ;-)
$cleanVal = $this->value;
$lPos = strrpos($cleanVal,'<');
$rPos = strrpos($cleanVal,'>');
if(($lPos > $rPos) || ($rPos === false && $lPos !== false)) $cleanVal .= '>';
// Remove broken link classes, we'll add them now
$cleanVal = eregi_replace('class="([^"]*) *broken *( [^"]*)?"','class="\\1\\2"', $cleanVal);
// Mark up broken links
$links = HTTP::getLinksIn($cleanVal);
if($links) {
$links = array_unique($links);
foreach($links as $link) {
$originalLink = $link;
$link = Director::makeRelative($link);
$broken = false;
if(ereg('^([A-Za-z0-9_\-]+)/?(#.*)?$', $link, $parts)) {
if(!DataObject::get_one("SiteTree", "\"URLSegment\" = '$parts[1]'", false)) {
$broken = true;
// Prevents execution timeouts if a page has 50 identical broken links by only highlighting them once
$alreadyHighlighted[$parts[1]] = true;
}
} else if($link == '' || $link[0] == '/') {
$broken = true;
} else if(ereg('^assets/',$link)) {
$link = str_replace(array('%20', '%5C', '%27'), array(' ', '\\', '\''), $link);
$link = Convert::raw2sql($link);
if(!DataObject::get_one("File", "\"Filename\" = '$link'", false)) {
$broken = true;
}
}
// Add a class. Note that this might create multiple class attributes, which are stripped below
if($broken) $cleanVal = eregi_replace("(<a)([^>]*href=\"{$originalLink}[^\"]*\"[^>]*>)",'\\1 class="broken"\\2', $cleanVal);
}
}
// Combined multiple classes into a single class
while( eregi('(<a[^>]*)class="([^"]*)"([^>]*)class="([^"]*)"([^>]*>)', $cleanVal) )
$cleanVal = eregi_replace('(<a[^>]*)class="([^"]*)"([^>]*)class="([^"]*)"([^>]*>)','\\1class="\\2 \\4"\\3\\5', $cleanVal);
// We can't use htmlentities as that messes with unicode
$cleanVal = str_replace(array("&","<",">"),array("&amp;","&lt;","&gt;"),$cleanVal);
// 97% instead of 100% to prevent horizontal scrollbars in IE7
$style = "width: 97%; height: " . ($this->rows * 16) . "px";
$class = "htmleditor";
$class = ($this->extraClass)?$class." ".$this->extraClass:$class;
return "<textarea class=\"$class\" rows=\"$this->rows\" cols=\"$this->cols\" style=\"$style\" tinymce=\"true\" id=\"" . $this->id() . "\" name=\"{$this->name}\">$cleanVal</textarea>";
}
/**
* This function has been created to explicit the functionnality.
*/
function setCSSClass($class){
$this->extraClass = $class;
return $this->createTag (
'textarea',
array (
'class' => $this->extraClass(),
'rows' => $this->rows,
'cols' => $this->cols,
'style' => "width: 97%; height: " . ($this->rows * 16) . "px", // Prevents horizontal scrollbars
'tinymce' => 'true',
'id' => $this->id(),
'name' => $this->name
),
htmlentities($this->value, ENT_COMPAT, 'UTF-8')
);
}
function saveInto($record) {
public function saveInto($record) {
if($record->escapeTypeForField($this->name) != 'xml') {
user_error("HTMLEditorField should save into an HTMLText or HTMLVarchar field.
If you don't, your template won't display properly.
This changed in version 2.2.2, so please update
your database field '$this->name'",
E_USER_WARNING
throw new Exception (
'HtmlEditorField->saveInto(): This field should save into a HTMLText or HTMLVarchar field.'
);
}
$content = $this->value;
$value = $this->value ? $this->value : '<p></p>';
$value = preg_replace('/src="([^\?]*)\?r=[0-9]+"/i', 'src="$1"', $value);
$content = preg_replace('/mce_real_src="[^"]+"/i', "", $content);
$content = eregi_replace('(<img[^>]* )width=([0-9]+)( [^>]*>|>)','\\1width="\\2"\\3', $content);
$content = eregi_replace('(<img[^>]* )height=([0-9]+)( [^>]*>|>)','\\1height="\\2"\\3', $content);
$content = eregi_replace('src="([^\?]*)\?r=[0-9]+"','src="\\1"', $content);
$content = eregi_replace('mce_src="([^\?]*)\?r=[0-9]+"','mce_src="\\1"', $content);
$content = preg_replace_callback('/(<img[^>]* )(width="|height="|src=")([^"]+)("[^>]* )(width="|height="|src=")([^"]+)("[^>]* )(width="|height="|src=")([^"]+)("[^>]*>)/i', "HtmlEditorField_dataValue_processImage", $content);
// If we don't have a containing block element, add a p tag.
if(!ereg("^[ \t\r\n]*<", $content)) $content = "<p>$content</p>";
$links = HTTP::getLinksIn($content);
$linkedPages = array();
$linkedFiles = array();
if($links) foreach($links as $link) {
$link = Director::makeRelative($link);
$document = new DOMDocument(null, 'UTF-8');
$document->strictErrorChecking = false;
$document->loadHTML($value);
// Populate link tracking for internal links & links to asset files.
if($links = $document->getElementsByTagName('a')) foreach($links as $link) {
$link = Director::makeRelative($link->getAttribute('href'));
if(preg_match('/^([A-Za-z0-9_-]+)\/?(#.*)?$/', $link, $parts)) {
$candidatePage = DataObject::get_one("SiteTree", "\"URLSegment\" = '" . urldecode( $parts[1] ). "'", false);
if($candidatePage) {
$linkedPages[] = $candidatePage->ID;
// This caused bugs in the publication script
// $candidatePage->destroy();
} else {
$record->HasBrokenLink = 1;
}
} else if($link == '' || $link[0] == '/') {
$record->HasBrokenLink = 1;
} else if($candidateFile = DataObject::get_one("File", "\"Filename\" = '" . Convert::raw2sql(urldecode($link)) . "'", false)) {
$linkedFiles[] = $candidateFile->ID;
// $candidateFile->destroy();
}
}
$images = HTTP::getImagesIn($content);
if($images) {
foreach($images as $image) {
$image = Director::makeRelative($image);
if(substr($image,0,7) == 'assets/') {
$candidateImage = DataObject::get_one("File", "\"Filename\" = '$image'");
if($candidateImage) $linkedFiles[] = $candidateImage->ID;
else $record->HasBrokenFile = 1;
}
}
}
if(preg_match('/\[sitetree_link id=([0-9]+)\]/i', $link, $matches)) {
$ID = $matches[1];
$fieldName = $this->name;
if($record->ID && $record->hasMethod('LinkTracking') && $linkTracking = $record->LinkTracking()) {
$linkTracking->removeByFilter("\"FieldName\" = '$fieldName' AND \"SiteTreeID\" = $record->ID");
if(isset($linkedPages)) foreach($linkedPages as $item) {
$linkTracking->add($item, array("FieldName" => $fieldName));
if($page = DataObject::get_by_id('SiteTree', $ID)) {
$linkedPages[] = $page->ID;
} else {
$record->HasBrokenLink = true;
}
} elseif($link[0] != '/' && $file = File::find($link)) {
$linkedFiles[] = $file->ID;
}
// $linkTracking->destroy();
}
if($record->ID && $record->hasMethod('ImageTracking') && $imageTracking = $record->ImageTracking()) {
$imageTracking->removeByFilter("\"FieldName\" = '$fieldName'");
if(isset($linkedFiles)) foreach($linkedFiles as $item) {
$imageTracking->add($item, array("FieldName" => $fieldName));
}
// $imageTracking->destroy();
}
// Sometimes clients will double-escape %20. Fix this up with this dirty hack
$content = str_replace('%2520', '%20', $content);
// Resample images, add default attributes and add to assets tracking.
if($images = $document->getElementsByTagName('img')) foreach($images as $img) {
if(!$image = File::find($path = Director::makeRelative($img->getAttribute('src')))) {
if(substr($path, 0, strlen(ASSETS_DIR) + 1) == ASSETS_DIR . '/') {
$record->HasBrokenFile = true;
}
continue;
}
$record->$fieldName = $content;
}
function rewriteLink($old, $new) {
$bases[] = "";
$bases[] = Director::baseURL();
$bases[] = Director::absoluteBaseURL();
foreach($bases as $base) {
$this->value = ereg_replace("(href=\"?)$base$old","\\1$new", $this->value);
// Resample the images if the width & height have changed.
$width = $img->getAttribute('width');
$height = $img->getAttribute('height');
if($width && $height && ($width != $image->getWidth() || $height != $image->getHeight())) {
$img->setAttribute('src', $image->ResizedImage($width, $height)->getRelativePath());
}
// Add default empty title & alt attributes.
if(!$img->getAttribute('alt')) $img->setAttribute('alt', '');
if(!$img->getAttribute('title')) $img->setAttribute('title', '');
// Add to the tracked files.
$linkedFiles[] = $image->ID;
}
$this->value = ereg_replace("(href=\"?)$base$old","\\1$new", $this->value);
return $this->value;
// Save file & link tracking data.
if($record->ID && $record->many_many('LinkTracking') && $tracker = $record->LinkTracking()) {
$tracker->removeByFilter(sprintf('"FieldName" = \'%s\' AND "SiteTreeID" = %d', $this->name, $record->ID));
if($linkedPages) foreach($linkedPages as $item) {
$tracker->add($item, array('FieldName' => $this->name));
}
}
if($record->ID && $record->many_many('ImageTracking') && $tracker = $record->ImageTracking()) {
$tracker->removeByFilter(sprintf('"FieldName" = \'%s\' AND "SiteTreeID" = %d', $this->name, $record->ID));
if($linkedFiles) foreach($linkedFiles as $item) {
$tracker->add($item, array('FieldName' => $this->name));
}
}
$record->{$this->name} = substr(simplexml_import_dom($document)->body->asXML(), 6, -7);
}
function performReadonlyTransformation() {
$field = new HtmlEditorField_readonly($this->name, $this->title, $this->value);
/**
* @return HtmlEditorField_Readonly
*/
public function performReadonlyTransformation() {
$field = new HtmlEditorField_Readonly($this->name, $this->title, $this->value);
$field->setForm($this->form);
$field->dontEscape = true;
return $field;
}
}
/**
@ -205,7 +144,7 @@ class HtmlEditorField extends TextareaField {
* @package forms
* @subpackage fields-formattedinput
*/
class HtmlEditorField_readonly extends ReadonlyField {
class HtmlEditorField_Readonly extends ReadonlyField {
function Field() {
$valforInput = $this->value ? Convert::raw2att($this->value) : "";
return "<span class=\"readonly typography\" id=\"" . $this->id() . "\">" . ( $this->value && $this->value != '<p></p>' ? $this->value : '<i>(not set)</i>' ) . "</span><input type=\"hidden\" name=\"".$this->name."\" value=\"".$valforInput."\" />";
@ -215,54 +154,6 @@ class HtmlEditorField_readonly extends ReadonlyField {
}
}
/**
* Proccesses HTML images into the correct proportions from
* the regular expression evaluated on the save.
*/
function HtmlEditorField_dataValue_processImage($parts) {
// The info could be in any order
$info[$parts[2]] = $parts[3]; $partSource[$parts[2]] = 3;
$info[$parts[5]] = $parts[6]; $partSource[$parts[5]] = 6;
$info[$parts[8]] = $parts[9]; $partSource[$parts[8]] = 9;
$src = Director::makeRelative($info['src="']);
if(substr($src,0,10) == '../assets/') $src = substr($src,3);
$width = $info['width="'];
$height = $info['height="'];
if(!$width || !$height) {
user_error("Can't find width/height in $text", E_USER_ERROR);
}
// find the image inserted from the HTML editor
$image = Image::find(urldecode($src));
// If we have an image, insert the resampled one into the src attribute; otherwise, leave the img src alone.
if($image && ($image instanceof Image) && ($image->getWidth() != $width) && ($image->getHeight() != $height)) {
// If we have an image, generate the resized image.
$resizedImage = $image->getFormattedImage('ResizedImage', $width, $height);
if($resizedImage) $parts[$partSource['src="']] = $resizedImage->getRelativePath();
}
$parts[0] = "";
$result = implode("", $parts);
// Insert an empty alt tag if there isn't one
if(strpos($result, "alt=") === false) {
$result = substr_replace($result, ' alt="" />', -3);
}
// Insert an empty title tag if there isn't one (IE shows the alt as title if no title tag)
if(strpos($result, "title=") === false) {
$result = substr_replace($result, ' title="" />', -3);
}
return $result;
}
/**
* External toolbar for the HtmlEditorField.
* This is used by the CMS

View File

@ -0,0 +1,127 @@
<?php
/**
* @package sapphire
* @subpackage tests
*/
class HtmlEditorFieldTest extends FunctionalTest {
public static $fixture_file = 'sapphire/tests/forms/HtmlEditorFieldTest.yml';
public static $use_draft_site = true;
public function testBasicSaving() {
$sitetree = new SiteTree();
$editor = new HtmlEditorField('Content');
$editor->setValue('Un-enclosed Content');
$editor->saveInto($sitetree);
$this->assertEquals('<p>Un-enclosed Content</p>', $sitetree->Content, 'Un-enclosed content is put in p tags.');
$editor->setValue('<p class="foo">Simple Content</p>');
$editor->saveInto($sitetree);
$this->assertEquals('<p class="foo">Simple Content</p>', $sitetree->Content, 'Attributes are preserved.');
$editor->setValue('<p>Unclosed Tag');
$editor->saveInto($sitetree);
$this->assertEquals('<p>Unclosed Tag</p>', $sitetree->Content, 'Unclosed tags are closed.');
}
public function testNullSaving() {
$sitetree = new SiteTree();
$editor = new HtmlEditorField('Content');
$editor->setValue(null);
$editor->saveInto($sitetree);
$this->assertEquals('<p/>', $sitetree->Content, 'Doesn\'t choke on null values.');
}
public function testLinkTracking() {
$sitetree = $this->objFromFixture('SiteTree', 'home');
$editor = new HtmlEditorField('Content');
$aboutID = $this->idFromFixture('SiteTree', 'about');
$contactID = $this->idFromFixture('SiteTree', 'contact');
$editor->setValue("<a href=\"[sitetree_link id=$aboutID]\">Example Link</a>");
$editor->saveInto($sitetree);
$this->assertEquals(array($aboutID => $aboutID), $sitetree->LinkTracking()->getIdList(), 'Basic link tracking works.');
$editor->setValue (
"<a href=\"[sitetree_link id=$aboutID]\"></a><a href=\"[sitetree_link id=$contactID]\"></a>"
);
$editor->saveInto($sitetree);
$this->assertEquals (
array($aboutID => $aboutID, $contactID => $contactID),
$sitetree->LinkTracking()->getIdList(),
'Tracking works on multiple links'
);
$editor->setValue(null);
$editor->saveInto($sitetree);
$this->assertEquals(array(), $sitetree->LinkTracking()->getIdList(), 'Link tracking is removed when links are.');
}
public function testFileLinkTracking() {
$sitetree = $this->objFromFixture('SiteTree', 'home');
$editor = new HtmlEditorField('Content');
$fileID = $this->idFromFixture('File', 'example_file');
$editor->setValue('<a href="assets/example.pdf">Example File</a>');
$editor->saveInto($sitetree);
$this->assertEquals (
array($fileID => $fileID), $sitetree->ImageTracking()->getIDList(), 'Links to assets are tracked.'
);
$editor->setValue(null);
$editor->saveInto($sitetree);
$this->assertEquals(array(), $sitetree->ImageTracking()->getIdList(), 'Asset tracking is removed with links.');
}
public function testImageInsertion() {
$sitetree = new SiteTree();
$editor = new HtmlEditorField('Content');
$editor->setValue('<img src="assets/example.jpg" />');
$editor->saveInto($sitetree);
$xml = new SimpleXMLElement($sitetree->Content);
$this->assertNotNull($xml['alt'], 'Alt tags are added by default.');
$this->assertNotNull($xml['title'], 'Title tags are added by default.');
$editor->setValue('<img src="assets/example.jpg" alt="foo" title="bar" />');
$editor->saveInto($sitetree);
$xml = new SimpleXMLElement($sitetree->Content);
$this->assertNotNull('foo', $xml['alt'], 'Alt tags are preserved.');
$this->assertNotNull('bar', $xml['title'], 'Title tags are preserved.');
}
public function testImageTracking() {
$sitetree = $this->objFromFixture('SiteTree', 'home');
$editor = new HtmlEditorField('Content');
$fileID = $this->idFromFixture('Image', 'example_image');
$editor->setValue('<img src="assets/example.jpg" />');
$editor->saveInto($sitetree);
$this->assertEquals (
array($fileID => $fileID), $sitetree->ImageTracking()->getIDList(), 'Inserted images are tracked.'
);
$editor->setValue(null);
$editor->saveInto($sitetree);
$this->assertEquals (
array(), $sitetree->ImageTracking()->getIDList(), 'Tracked images are deleted when removed.'
);
}
public function testMultiLineSaving() {
$sitetree = $this->objFromFixture('SiteTree', 'home');
$editor = new HtmlEditorField('Content');
$editor->setValue("<p>First Paragraph</p>\n\n<p>Second Paragraph</p>");
$editor->saveInto($sitetree);
$this->assertEquals("<p>First Paragraph</p>\n\n<p>Second Paragraph</p>", $sitetree->Content);
}
}

View File

@ -0,0 +1,15 @@
SiteTree:
home:
Title: Home Page
about:
Title: About Us
contact:
Title: Contact Us
File:
example_file:
Name: example.pdf
Image:
example_image:
Name: example.jpg