throw new UserInputException('text', 'tooLong');
}
- if ($this->enableBBCodes && $this->allowedBBCodesPermission) {
+ /*if ($this->enableBBCodes && $this->allowedBBCodesPermission) {
$disallowedBBCodes = BBCodeParser::getInstance()->validateBBCodes($this->text, ArrayUtil::trim(explode(',', WCF::getSession()->getPermission($this->allowedBBCodesPermission))));
if (!empty($disallowedBBCodes)) {
WCF::getTPL()->assign('disallowedBBCodes', $disallowedBBCodes);
throw new UserInputException('text', 'disallowedBBCodes');
}
- }
+ }*/
// search for censored words
if (ENABLE_CENSORSHIP) {
--- /dev/null
+<?php
+namespace wcf\system\bbcode;
+use wcf\system\exception\SystemException;
+use wcf\util\JSON;
+use wcf\util\StringUtil;
+
+/**
+ * Parses bbcodes and transforms them into the custom HTML element <woltlab-bbcode>
+ * that can be safely passed through HTMLPurifier's validation mechanism.
+ *
+ * All though not exactly required for all bbcodes, the actual output of an bbcode
+ * cannot be forseen and potentially conflict with HTMLPurifier's whitelist. Examples
+ * are <iframe> or other embedded media that is allowed as a result of a bbcode, but
+ * not allowed to be directly provided by a user.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2016 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.bbcode
+ * @category Community Framework
+ */
+class HtmlBBCodeParser extends BBCodeParser {
+ /**
+ * list of open tags with name and uuid
+ * @var array
+ */
+ protected $openTagIdentifiers = [];
+
+ /**
+ * regex for valid bbcode names
+ * @var string
+ */
+ protected $validBBCodePattern = '~^[a-z](?:[a-z0-9\-_]+)?$~';
+
+ /**
+ * @inheritDoc
+ */
+ public function parse($text) {
+ $this->setText($text);
+
+ // difference to the original implementation: sourcecode bbcodes are handled too
+ $this->buildTagArray(false);
+
+ $this->buildXMLStructure();
+ $this->buildParsedString();
+
+ return $this->parsedText;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function buildParsedString() {
+ // reset parsed text
+ $this->parsedText = '';
+
+ // reset identifiers for open tags
+ $this->openTagIdentifiers = [];
+
+ // create text buffer
+ $buffer =& $this->parsedText;
+
+ // stack of buffered tags
+ $bufferedTagStack = array();
+
+ // loop through the tags
+ $i = -1;
+ foreach ($this->tagArray as $i => $tag) {
+ // append text to buffer
+ $buffer .= $this->textArray[$i];
+
+ if ($tag['closing']) {
+ // get buffered opening tag
+ $openingTag = end($bufferedTagStack);
+
+ // closing tag
+ if ($openingTag && $openingTag['name'] == $tag['name']) {
+ $hideBuffer = false;
+ // insert buffered content as attribute value
+ foreach ($this->bbcodes[$tag['name']]->getAttributes() as $attribute) {
+ if ($attribute->useText && !isset($openingTag['attributes'][$attribute->attributeNo])) {
+ $openingTag['attributes'][$attribute->attributeNo] = $buffer;
+ $hideBuffer = true;
+ break;
+ }
+ }
+
+ // validate tag attributes again
+ if ($this->isValidTag($openingTag)) {
+ // build tag
+ if ($this->bbcodes[$tag['name']]->className) {
+ // difference to the original implementation: use the custom HTML element than to process them directly
+ $parsedTag = $this->compileTag($openingTag, $buffer, $tag);
+ }
+ else {
+ // build tag
+ $parsedTag = $this->buildOpeningTag($openingTag);
+ $closingTag = $this->buildClosingTag($tag);
+ if (!empty($closingTag) && $hideBuffer) $parsedTag .= $buffer.$closingTag;
+ }
+ }
+ else {
+ $parsedTag = $openingTag['source'].$buffer.$tag['source'];
+ }
+
+ // close current buffer
+ array_pop($bufferedTagStack);
+
+ // open previous buffer
+ if (count($bufferedTagStack) > 0) {
+ $bufferedTag =& $bufferedTagStack[count($bufferedTagStack) - 1];
+ $buffer =& $bufferedTag['buffer'];
+ }
+ else {
+ $buffer =& $this->parsedText;
+ }
+
+ // append parsed tag
+ $buffer .= $parsedTag;
+ }
+ else {
+ $buffer .= $this->buildClosingTag($tag);
+ }
+ }
+ else {
+ // opening tag
+ if ($this->needBuffering($tag)) {
+ // start buffering
+ $tag['buffer'] = '';
+ $bufferedTagStack[] = $tag;
+ $buffer =& $bufferedTagStack[(count($bufferedTagStack) - 1)]['buffer'];
+ }
+ else {
+ $buffer .= $this->buildOpeningTag($tag);
+ }
+ }
+ }
+
+ if (isset($this->textArray[$i + 1])) $this->parsedText .= $this->textArray[$i + 1];
+ }
+
+ /**
+ * Compiles tag fragments into the custom HTML element.
+ *
+ * @param array $openingTag opening tag data
+ * @param string $content content between opening and closing tag
+ * @param array $closingTag closing tag data
+ * @return string custom HTML element
+ */
+ protected function compileTag(array $openingTag, $content, array $closingTag) {
+ return $this->buildOpeningTag($openingTag) . $content . $this->buildClosingTag($closingTag);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildOpeningTag(array $tag) {
+ $name = strtolower($tag['name']);
+ if (!$this->isValidBBCodeName($name)) {
+ return $tag['source'];
+ }
+
+ $uuid = StringUtil::getUUID();
+ $this->openTagIdentifiers[] = [
+ 'name' => $name,
+ 'uuid' => $uuid
+ ];
+
+ $attributes = '';
+ if (!empty($tag['attributes'])) {
+ // uses base64 encoding to avoid an "escape" nightmare
+ $attributes = ' data-attributes="' . base64_encode(JSON::encode($tag['attributes'])) . '"';
+ }
+
+ return '<woltlab-metacode-marker data-name="' . $name . '" data-uuid="' . $uuid . '"' . $attributes . ' />';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function buildClosingTag(array $tag) {
+ $name = strtolower($tag['name']);
+ if (!$this->isValidBBCodeName($name)) {
+ return $tag['source'];
+ }
+
+ $data = array_pop($this->openTagIdentifiers);
+ if ($data['name'] !== $name) {
+ throw new SystemException("Tag mismatch, expected '".$name."', got '".$data['name']."'.");
+ }
+
+ return '<woltlab-metacode-marker data-uuid="' . $data['uuid'] . '" />';
+ }
+
+ /**
+ * Returns true if provided name is a valid bbcode identifier.
+ *
+ * @param string $name bbcode identifier
+ * @return boolean true if provided name is a valid bbcode identifier
+ */
+ protected function isValidBBCodeName($name) {
+ return preg_match($this->validBBCodePattern, $name) === 1;
+ }
+}
<?php
namespace wcf\system\html\input;
+use wcf\system\bbcode\HtmlBBCodeParser;
use wcf\system\html\input\filter\IHtmlInputFilter;
use wcf\system\html\input\filter\MessageHtmlInputFilter;
use wcf\system\html\input\node\HtmlInputNodeProcessor;
+use wcf\util\StringUtil;
/**
* TOOD documentation
protected $htmlInputNodeProcessor;
public function process($html) {
+ // enforce consistent newlines
+ $html = StringUtil::unifyNewlines($html);
+
+ // transform bbcodes into metacode markers
+ $html = HtmlBBCodeParser::getInstance()->parse($html);
+
// filter HTML
$html = $this->getHtmlInputFilter()->apply($html);
'data-user-id' => 'Number',
'data-username' => 'Text'
]);
+
+ $definition->addElement('woltlab-metacode', 'Inline', 'Inline', '', [
+ 'data-attributes' => 'Text',
+ 'data-name' => 'Text'
+ ]);
+
+ $definition->addElement('woltlab-metacode-marker', 'Inline', 'Empty', '', [
+ 'data-attributes' => 'Text',
+ 'data-name' => 'Text',
+ 'data-uuid' => 'Text'
+ ]);
}
}
* @since 2.2
*/
class HtmlInputNodeProcessor extends HtmlNodeProcessor {
- public function load($html) {
- parent::load($html);
-
- $this->nodeData = [];
- }
-
public function process() {
- $woltlabMention = new HtmlInputNodeWoltlabMention();
- $woltlabMention->process($this);
+ // process metacode markers first
+ $this->invokeHtmlNode(new HtmlInputNodeWoltlabMetacodeMarker());
+
+ // handle static converters
+ $this->invokeHtmlNode(new HtmlInputNodeWoltlabMetacode());
}
}
<?php
namespace wcf\system\html\input\node;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
/**
* TOOD documentation
* @since 2.2
*/
-class HtmlInputNodeWoltlabMention implements IHtmlInputNode {
- public function process(HtmlInputNodeProcessor $htmlInputNodeProcessor) {
+class HtmlInputNodeWoltlabMention extends AbstractHtmlNode {
+ protected $tagName = 'woltlab-mention';
+
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
$userIds = [];
/** @var \DOMElement $mention */
- foreach ($htmlInputNodeProcessor->getDocument()->getElementsByTagName('woltlab-mention') as $mention) {
+ foreach ($elements as $mention) {
$userId = intval($mention->getAttribute('data-user-id'));
if ($userId) {
$userIds[] = $userId;
}
}
+
+ public function replaceTag(array $data) {
+ return null;
+ }
}
--- /dev/null
+<?php
+namespace wcf\system\html\input\node;
+use wcf\system\bbcode\HtmlBBCodeParser;
+use wcf\system\exception\SystemException;
+use wcf\system\html\metacode\converter\IMetacodeConverter;
+use wcf\system\html\metacode\converter\SimpleMetacodeConverter;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
+use wcf\util\DOMUtil;
+use wcf\util\StringUtil;
+
+/**
+ * TOOD documentation
+ * @since 2.2
+ */
+class HtmlInputNodeWoltlabMetacode extends AbstractHtmlNode {
+ /**
+ * static mapping of attribute-less metacodes that map to
+ * an exact HTML tag without the need of further processing
+ *
+ * @var string[]
+ */
+ public $simpleMapping = [
+ 'b' => 'strong',
+ 'i' => 'em',
+ 'tt' => 'kbd',
+ 'u' => 'u'
+ ];
+
+ protected $tagName = 'woltlab-metacode';
+
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+ /** @var IMetacodeConverter[] $converters */
+ $converters = [];
+
+ /** @var \DOMElement $element */
+ foreach ($elements as $element) {
+ $name = $element->getAttribute('data-name');
+ if ($name === 'abstract') {
+ continue;
+ }
+
+ // handle simple mapping types
+ if (isset($name, $this->simpleMapping)) {
+ $newElement = $element->ownerDocument->createElement($this->simpleMapping[$name]);
+ DOMUtil::replaceElement($element, $newElement);
+
+ continue;
+ }
+
+ $attributes = $element->getAttribute('data-attributes');
+ if (!empty($attributes)) $attributes = @json_decode(base64_decode($attributes), true);
+ if (!is_array($attributes)) $attributes = [];
+
+ // check for converters
+ $converter = (isset($converters[$name])) ? $converters[$name] : null;
+ if ($converter === null) {
+ $className = 'wcf\\system\\html\\metacode\\converter\\' . $name . 'MetacodeConverter';
+ if (class_exists($className)) {
+ $converter = new $className();
+
+ $converters[$name] = $converter;
+ }
+ }
+
+ if ($converter === null) {
+ // no available converter, metacode will be handled during output generation
+ continue;
+ }
+
+ /** @var IMetacodeConverter $converter */
+ if ($converter->validateAttributes($attributes)) {
+ $newElement = $converter->convert(DOMUtil::childNodesToFragment($element), $attributes);
+ if (!($newElement instanceof \DOMElement)) {
+ throw new SystemException("Expected a valid DOMElement as return value.");
+ }
+
+ DOMUtil::replaceElement($element, $newElement);
+ }
+ else {
+ // attributes are invalid, remove element from DOM
+ DOMUtil::removeNode($element, true);
+ }
+
+
+ continue;
+ $parsedTag = HtmlBBCodeParser::getInstance()->getHtmlOutput($name, $attributes);
+
+ $nodeIdentifier = StringUtil::getRandomID();
+ $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
+ 'parsedTag' => $parsedTag
+ ]);
+
+ $htmlNodeProcessor->renameTag($metacode, 'wcfNode-' . $nodeIdentifier);
+ }
+ }
+
+ public function replaceTag(array $data) {
+ return $data['parsedTag'];
+ }
+
+ protected function getPlaceholderElement() {
+ return new \DOMElement('woltlab-placeholder');
+ }
+}
--- /dev/null
+<?php
+namespace wcf\system\html\input\node;
+use wcf\system\exception\SystemException;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
+use wcf\util\DOMUtil;
+use wcf\util\JSON;
+
+/**
+ * TOOD documentation
+ * @since 2.2
+ */
+class HtmlInputNodeWoltlabMetacodeMarker extends AbstractHtmlNode {
+ public $blockElements = ['code', 'quote'];
+ public $inlineElements = ['b', 'color', 'i', 'tt', 'u'];
+ public $sourceElements = ['code', 'tt'];
+
+ protected $tagName = 'woltlab-metacode-marker';
+
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+ // collect pairs
+ $pairs = $this->buildPairs($elements);
+
+ // validate pairs and remove items that lack an opening/closing element
+ $pairs = $this->validatePairs($pairs);
+
+ $pairs = $this->revertMarkerInsideCodeBlocks($pairs);
+
+ // group pairs by tag name
+ $groups = $this->groupPairsByName($pairs);
+
+ // convert pairs into HTML or metacode
+ $this->convertGroups($groups);
+ }
+
+ public function replaceTag(array $data) {
+ return $data['parsedTag'];
+ }
+
+ /**
+ * @param array $pairs
+ * @return array
+ */
+ protected function revertMarkerInsideCodeBlocks(array $pairs) {
+ $isInsideCode = function(\DOMElement $element) {
+ $parent = $element;
+ while ($parent = $parent->parentNode) {
+ $nodeName = $parent->nodeName;
+
+ if ($nodeName === 'code' || $nodeName === 'kbd') {
+ return true;
+ }
+ else if ($nodeName === 'woltlab-metacode') {
+ $name = $parent->getAttribute('data-name');
+ if ($name === 'code' || $name === 'tt') {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ };
+
+ foreach ($pairs as $uuid => $pair) {
+ if ($isInsideCode($pair['open']) || $isInsideCode($pair['close'])) {
+ $this->convertToBBCode($pair);
+
+ unset($pairs[$uuid]);
+ }
+ }
+
+ return $pairs;
+ }
+
+ protected function buildPairs(array $elements) {
+ $pairs = [];
+ /** @var \DOMElement $element */
+ foreach ($elements as $element) {
+ $attributes = $element->getAttribute('data-attributes');
+ $name = $element->getAttribute('data-name');
+ $uuid = $element->getAttribute('data-uuid');
+
+ if (!isset($pairs[$uuid])) {
+ $pairs[$uuid] = [
+ 'attributes' => [],
+ 'close' => null,
+ 'name' => '',
+ 'open' => null
+ ];
+ }
+
+ if ($name) {
+ $pairs[$uuid]['attributes'] = $attributes;
+ $pairs[$uuid]['name'] = $name;
+ $pairs[$uuid]['open'] = $element;
+ }
+ else {
+ $pairs[$uuid]['close'] = $element;
+ }
+ }
+
+ return $pairs;
+ }
+
+ protected function validatePairs(array $pairs) {
+ foreach ($pairs as $uuid => $data) {
+ if ($data['close'] === null) {
+ DOMUtil::removeNode($data['open']);
+ }
+ else if ($data['open'] === null) {
+ DOMUtil::removeNode($data['close']);
+ }
+ else {
+ continue;
+ }
+
+ unset($pairs[$uuid]);
+ }
+
+ return $pairs;
+ }
+
+ protected function groupPairsByName(array $pairs) {
+ $groups = [];
+ foreach ($pairs as $uuid => $data) {
+ $name = $data['name'];
+
+ if (!isset($groups[$name])) {
+ $groups[$name] = [];
+ }
+
+ $groups[$name][] = [
+ 'attributes' => $data['attributes'],
+ 'close' => $data['close'],
+ 'open' => $data['open']
+ ];
+ }
+
+ return $groups;
+ }
+
+ protected function convertGroups(array $groups) {
+ // process source elements first
+ foreach ($this->sourceElements as $name) {
+ if (in_array($name, $this->blockElements)) {
+ if (isset($groups[$name])) {
+ for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+ $data = $groups[$name][$i];
+ $this->convertBlockElement($name, $data['open'], $data['close'], $data['attributes']);
+ }
+
+ unset($groups[$name]);
+ }
+ }
+ else {
+ if (isset($groups[$name])) {
+ for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+ $data = $groups[$name][$i];
+ $this->convertInlineElement($name, $data['open'], $data['close'], $data['attributes']);
+ }
+
+ unset($groups[$name]);
+ }
+ }
+ }
+
+ // remaining block elements
+ foreach ($this->blockElements as $name) {
+ if (isset($groups[$name])) {
+ for ($i = 0, $length = count($groups[$name]); $i < $length; $i++) {
+ $data = $groups[$name][$i];
+ $this->convertBlockElement($name, $data['open'], $data['close'], $data['attributes']);
+ }
+
+ unset($groups[$name]);
+ }
+ }
+
+ // treat remaining elements as inline elements
+ foreach ($groups as $name => $pairs) {
+ for ($i = 0, $length = count($pairs); $i < $length; $i++) {
+ $data = $pairs[$i];
+ $this->convertInlineElement($name, $data['open'], $data['close'], $data['attributes']);
+ }
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param \DOMElement $start
+ * @param \DOMElement $end
+ * @param string $attributes
+ */
+ protected function convertBlockElement($name, $start, $end, $attributes) {
+ $commonAncestor = DOMUtil::getCommonAncestor($start, $end);
+ $lastElement = DOMUtil::splitParentsUntil($end, $commonAncestor, false);
+
+ $container = $start->ownerDocument->createElement('woltlab-metacode');
+ $container->setAttribute('data-name', $name);
+ $container->setAttribute('data-attributes', $attributes);
+
+ DOMUtil::insertAfter($container, $start);
+ DOMUtil::removeNode($start);
+
+ DOMUtil::moveNodesInto($container, $lastElement, $commonAncestor);
+
+ DOMUtil::removeNode($end);
+ }
+
+ /**
+ * @param string $name
+ * @param \DOMElement $start
+ * @param \DOMElement $end
+ * @param string $attributes
+ */
+ protected function convertInlineElement($name, $start, $end, $attributes) {
+ if ($start->parentNode === $end->parentNode) {
+ $this->wrapContent($name, $attributes, $start, $end);
+
+ DOMUtil::removeNode($start);
+ DOMUtil::removeNode($end);
+ }
+ else {
+ $commonAncestor = DOMUtil::getCommonAncestor($start, $end);
+ $endAncestor = DOMUtil::getParentBefore($end, $commonAncestor);
+
+ $element = $this->wrapContent($name, $attributes, $start, null);
+ DOMUtil::removeNode($start);
+
+ $element = DOMUtil::getParentBefore($element, $commonAncestor);
+ while ($element = $element->nextSibling) {
+ if ($element->nodeType === XML_TEXT_NODE) {
+ // ignore text nodes between tags
+ continue;
+ }
+
+ if ($element !== $endAncestor) {
+ if ($this->isBlockElement($element)) {
+ $this->wrapContent($name, $attributes, $element->firstChild, null);
+ }
+ else {
+ $this->wrapContent($name, $attributes, $element, null);
+ }
+ }
+ else {
+ $this->wrapContent($name, $attributes, null, $end);
+
+ DOMUtil::removeNode($end);
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * @param string $name
+ * @param string $attributes
+ * @param \DOMElement|null $startNode
+ * @param \DOMElement|null $endNode
+ * @return \DOMElement
+ */
+ protected function wrapContent($name, $attributes, $startNode, $endNode) {
+ $element = ($startNode) ? $startNode->ownerDocument->createElement('woltlab-metacode') : $endNode->ownerDocument->createElement('woltlab-metacode');
+ $element->setAttribute('data-name', $name);
+ $element->setAttribute('data-attributes', $attributes);
+
+ if ($startNode) {
+ DOMUtil::insertBefore($element, $startNode);
+
+ while ($sibling = $element->nextSibling) {
+ $element->appendChild($sibling);
+
+ if ($sibling === $endNode) {
+ break;
+ }
+ }
+ }
+ else {
+ DOMUtil::insertAfter($element, $endNode);
+
+ while ($sibling = $element->previousSibling) {
+ DOMUtil::prepend($sibling, $element);
+
+ if ($sibling === $startNode) {
+ break;
+ }
+ }
+ }
+
+ return $element;
+ }
+
+ /**
+ * Returns true if provided node is a block element.
+ *
+ * @param \DOMNode $node node
+ * @return boolean true for certain block elements
+ */
+ protected function isBlockElement(\DOMNode $node) {
+ switch ($node->nodeName) {
+ case 'blockquote':
+ case 'code':
+ case 'div':
+ case 'p':
+ return true;
+ }
+
+ return false;
+ }
+
+ protected function convertToBBCode(array $pair) {
+ /** @var \DOMElement $start */
+ $start = $pair['open'];
+ /** @var \DOMElement $end */
+ $end = $pair['close'];
+
+ $attributes = '';
+ if (!empty($pair['attributes'])) {
+ $pair['attributes'] = base64_decode($pair['attributes'], true);
+ if ($pair['attributes'] !== false) {
+ try {
+ $pair['attributes'] = JSON::decode($pair['attributes']);
+ }
+ catch (SystemException $e) {
+ $pair['attributes'] = [];
+ }
+
+ if (!empty($pair['attributes'])) {
+ foreach ($pair['attributes'] as &$attribute) {
+ $attribute = "'" . addcslashes($attribute, "'") . "'";
+ }
+ unset($attribute);
+
+ $attributes = '=' . implode(",", $attributes);
+ }
+ }
+ }
+
+ $textNode = $start->ownerDocument->createTextNode('[' . $pair['name'] . $attributes . ']');
+ DOMUtil::insertBefore($textNode, $start);
+ DOMUtil::removeNode($start);
+
+ $textNode = $end->ownerDocument->createTextNode('[/' . $pair['name'] . ']');
+ DOMUtil::insertBefore($textNode, $end);
+ DOMUtil::removeNode($end);
+ }
+}
<?php
namespace wcf\system\html\input\node;
+use wcf\system\html\node\IHtmlNode;
/**
* TOOD documentation
* @since 2.2
*/
-interface IHtmlInputNode {
- public function process(HtmlInputNodeProcessor $htmlInputNodeProcessor);
-}
+interface IHtmlInputNode extends IHtmlNode {}
--- /dev/null
+<?php
+namespace wcf\system\html\metacode\converter;
+
+abstract class AbstractMetacodeConverter implements IMetacodeConverter {
+ /**
+ * @inheritDoc
+ */
+ public function validateAttributes(array $attributes) {
+ return true;
+ }
+}
--- /dev/null
+<?php
+namespace wcf\system\html\metacode\converter;
+
+class ColorMetacodeConverter extends AbstractMetacodeConverter {
+ /**
+ * @inheritDoc
+ */
+ public function convert(\DOMDocumentFragment $fragment, array $attributes) {
+ $woltlabColor = $fragment->ownerDocument->createElement('woltlab-color');
+ $woltlabColor->setAttribute('class', 'woltlab-color-' . strtoupper(substr($attributes[0], 1)));
+ $woltlabColor->appendChild($fragment);
+
+ return $woltlabColor;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validateAttributes(array $attributes) {
+ if (count($attributes) !== 1) {
+ return false;
+ }
+
+ // validates if code is a valid (short) HEX color code
+ if (!preg_match('~^#[A-F0-9]{3}(?:[A-F0-9]{3})?$~i', $attributes[0])) {
+ return false;
+ }
+
+ return true;
+ }
+}
--- /dev/null
+<?php
+namespace wcf\system\html\metacode\converter;
+
+interface IMetacodeConverter {
+ /**
+ * Converts a known metacode into the HTML representation normally used by the WYSIWYG
+ * editor. This process is designed to turn simple bbcodes into their HTML counterpart
+ * without forcing the bbcode to be evaluated each time.
+ *
+ * The fragment must be inserted into your returned DOM element.
+ *
+ * @param \DOMDocumentFragment $fragment fragment containing all child nodes, must be appended to returned element
+ * @param array $attributes list of attributes
+ * @return \DOMElement new DOM element
+ */
+ public function convert(\DOMDocumentFragment $fragment, array $attributes);
+
+ /**
+ * Validates attributes before any DOM modification occurs.
+ *
+ * @param array $attributes list of attributes
+ * @return boolean false if attributes did not match the converter's expectation
+ */
+ public function validateAttributes(array $attributes);
+}
--- /dev/null
+<?php
+namespace wcf\system\html\metacode\converter;
+
+class QuoteMetacodeConverter extends AbstractMetacodeConverter {
+ /**
+ * @inheritDoc
+ */
+ public function convert(\DOMDocumentFragment $fragment, array $attributes) {
+ $element = $fragment->ownerDocument->createElement('blockquote');
+ $element->setAttribute('class', 'quoteBox');
+ $element->setAttribute('data-quote-title', (isset($attributes[0])) ? $attributes[0] : '');
+ $element->setAttribute('data-quote-url', (isset($attributes[1])) ? $attributes[1] : '');
+ $element->appendChild($fragment);
+
+ return $element;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function validateAttributes(array $attributes) {
+ // 0, 1 or 2 attributes
+ return (count($attributes) <= 2);
+ }
+}
--- /dev/null
+<?php
+namespace wcf\system\html\node;
+
+abstract class AbstractHtmlNode implements IHtmlNode {
+ protected $tagName = '';
+
+ public function getTagName() {
+ return $this->tagName;
+ }
+
+ public function replaceTag(array $data) {
+ throw new \BadMethodCallException("Method replaceTag() is not supported by ".get_class($this));
+ }
+}
<?php
namespace wcf\system\html\node;
+use wcf\system\exception\SystemException;
/**
* TOOD documentation
*/
protected $document;
+ protected $nodeData = [];
+
public function load($html) {
$this->document = new \DOMDocument();
// provide a proper way to add custom HTML elements (even though explicitly allowed
// in HTML5) and the input HTML has already been sanitized by HTMLPurifier
@$this->document->loadHTML($html);
+
+ $this->nodeData = [];
}
public function getHtml() {
$html = mb_convert_encoding($html, 'UTF-8', 'HTML-ENTITIES');
+ /** @var IHtmlNode $obj */
+ foreach ($this->nodeData as $data) {
+ $obj = $data['object'];
+ $string = $obj->replaceTag($data['data']);
+ $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>.*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($string) {
+ $string = str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
+
+ return $string;
+ }, $html);
+
+ }
+
return $html;
}
$element->parentNode->removeChild($element);
}
+
+ public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data) {
+ $this->nodeData[] = [
+ 'data' => $data,
+ 'identifier' => $nodeIdentifier,
+ 'object' => $htmlNode
+ ];
+ }
+
+ protected function invokeHtmlNode(IHtmlNode $htmlNode) {
+ $tagName = $htmlNode->getTagName();
+ if (empty($tagName)) {
+ throw new SystemException("Missing tag name for " . get_class($htmlNode));
+ }
+
+ $elements = [];
+ foreach ($this->getDocument()->getElementsByTagName($tagName) as $element) {
+ $elements[] = $element;
+ }
+
+ if (!empty($elements)) {
+ $htmlNode->process($elements, $this);
+ }
+ }
}
--- /dev/null
+<?php
+namespace wcf\system\html\node;
+
+interface IHtmlNode {
+ public function getTagName();
+
+ /**
+ * @param \DOMElement[] $elements
+ * @param HtmlNodeProcessor $htmlNodeProcessor
+ * @return mixed
+ */
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor);
+
+ public function replaceTag(array $data);
+}
use wcf\system\html\node\HtmlNodeProcessor;
use wcf\system\html\output\node\HtmlOutputNodeBlockquote;
use wcf\system\html\output\node\HtmlOutputNodeWoltlabMention;
-use wcf\system\html\output\node\IHtmlOutputNode;
/**
* TOOD documentation
* @since 2.2
*/
class HtmlOutputNodeProcessor extends HtmlNodeProcessor {
- protected $nodeData = [];
-
- public function load($html) {
- parent::load($html);
-
- $this->nodeData = [];
- }
-
public function process() {
// TODO: this should be dynamic to some extent
- $quoteNode = new HtmlOutputNodeBlockquote();
- $quoteNode->process($this);
-
- $woltlabMentionNode = new HtmlOutputNodeWoltlabMention();
- $woltlabMentionNode->process($this);
- }
-
- public function getHtml() {
- $html = parent::getHtml();
-
- /** @var IHtmlOutputNode $obj */
- foreach ($this->nodeData as $data) {
- $obj = $data['object'];
- $string = $obj->replaceTag($data['data']);
- $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>.*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($string) {
- $string = str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
-
- return $string;
- }, $html);
-
- }
-
- return $html;
- }
-
- public function addNodeData(IHtmlOutputNode $htmlOutputNode, $nodeIdentifier, array $data) {
- $this->nodeData[] = [
- 'data' => $data,
- 'identifier' => $nodeIdentifier,
- 'object' => $htmlOutputNode
- ];
+ $this->invokeHtmlNode(new HtmlOutputNodeBlockquote());
+ $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMention());
}
}
<?php
namespace wcf\system\html\output\node;
use wcf\system\application\ApplicationHandler;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
use wcf\system\request\RouteHandler;
use wcf\system\WCF;
use wcf\util\StringUtil;
* TOOD documentation
* @since 2.2
*/
-class HtmlOutputNodeBlockquote implements IHtmlOutputNode {
- public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor) {
- $elements = $htmlOutputNodeProcessor->getDocument()->getElementsByTagName('blockquote');
- while ($elements->length) {
- /** @var \DOMElement $blockquote */
- $blockquote = $elements->item(0);
-
- if ($blockquote->getAttribute('class') === 'quoteBox') {
+class HtmlOutputNodeBlockquote extends AbstractHtmlNode {
+ protected $tagName = 'blockquote';
+
+ /**
+ * @inheritDoc
+ */
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
+ foreach ($elements as $element) {
+ if ($element->getAttribute('class') === 'quoteBox') {
$nodeIdentifier = StringUtil::getRandomID();
- $htmlOutputNodeProcessor->addNodeData($this, $nodeIdentifier, [
- 'title' => ($blockquote->hasAttribute('data-quote-title')) ? $blockquote->getAttribute('data-quote-title') : '',
- 'url' => ($blockquote->hasAttribute('data-quote-url')) ? $blockquote->getAttribute('data-quote-url') : ''
+ $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
+ 'title' => ($element->hasAttribute('data-quote-title')) ? $element->getAttribute('data-quote-title') : '',
+ 'url' => ($element->hasAttribute('data-quote-url')) ? $element->getAttribute('data-quote-url') : ''
]);
- $htmlOutputNodeProcessor->renameTag($blockquote, 'wcfNode-' . $nodeIdentifier);
+ $htmlNodeProcessor->renameTag($element, 'wcfNode-' . $nodeIdentifier);
}
else {
- $htmlOutputNodeProcessor->unwrapContent($blockquote);
+ $htmlNodeProcessor->unwrapContent($element);
}
}
}
namespace wcf\system\html\output\node;
use wcf\data\user\UserProfile;
use wcf\system\cache\runtime\UserProfileRuntimeCache;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\AbstractHtmlNode;
+use wcf\system\html\node\HtmlNodeProcessor;
use wcf\system\WCF;
+use wcf\util\DOMUtil;
use wcf\util\StringUtil;
/**
* TOOD documentation
* @since 2.2
*/
-class HtmlOutputNodeWoltlabMention implements IHtmlOutputNode {
+class HtmlOutputNodeWoltlabMention extends AbstractHtmlNode {
+ protected $tagName = 'woltlab-mention';
+
/**
* @var UserProfile[]
*/
protected $userProfiles;
- public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor) {
+ /**
+ * @inheritDoc
+ */
+ public function process(array $elements, HtmlNodeProcessor $htmlNodeProcessor) {
$this->userProfiles = [];
$userIds = [];
- $elements = $htmlOutputNodeProcessor->getDocument()->getElementsByTagName('woltlab-mention');
- while ($elements->length) {
- /** @var \DOMElement $mention */
- $mention = $elements->item(0);
-
- $userId = ($mention->hasAttribute('data-user-id')) ? intval($mention->getAttribute('data-user-id')) : 0;
- $username = ($mention->hasAttribute('data-username')) ? StringUtil::trim($mention->getAttribute('data-username')) : '';
+ foreach ($elements as $element) {
+ $userId = ($element->hasAttribute('data-user-id')) ? intval($element->getAttribute('data-user-id')) : 0;
+ $username = ($element->hasAttribute('data-username')) ? StringUtil::trim($element->getAttribute('data-username')) : '';
if ($userId === 0 || $username === '') {
- $mention->parentNode->removeChild($mention);
+ DOMUtil::removeNode($element);
continue;
}
$userIds[] = $userId;
$nodeIdentifier = StringUtil::getRandomID();
- $htmlOutputNodeProcessor->addNodeData($this, $nodeIdentifier, [
+ $htmlNodeProcessor->addNodeData($this, $nodeIdentifier, [
'userId' => $userId,
'username' => $username
]);
- $htmlOutputNodeProcessor->renameTag($mention, 'wcfNode-' . $nodeIdentifier);
+ $htmlNodeProcessor->renameTag($element, 'wcfNode-' . $nodeIdentifier);
}
if (!empty($userIds)) {
<?php
namespace wcf\system\html\output\node;
-use wcf\system\html\output\HtmlOutputNodeProcessor;
+use wcf\system\html\node\IHtmlNode;
/**
* TOOD documentation
* @since 2.2
*/
-interface IHtmlOutputNode {
- public function process(HtmlOutputNodeProcessor $htmlOutputNodeProcessor);
-
- public function replaceTag(array $data);
-}
+interface IHtmlOutputNode extends IHtmlNode {}
--- /dev/null
+<?php
+namespace wcf\util;
+use wcf\system\exception\SystemException;
+
+/**
+ * Provides helper methods to work with PHP's DOM implementation.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2016 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage util
+ * @category Community Framework
+ */
+final class DOMUtil {
+ /**
+ * Moves all child nodes from given element into a document fragment.
+ *
+ * @param \DOMElement $element element
+ * @return \DOMDocumentFragment document fragment containing all child nodes from `$element`
+ */
+ public static function childNodesToFragment(\DOMElement $element) {
+ $fragment = $element->ownerDocument->createDocumentFragment();
+
+ while ($element->hasChildNodes()) {
+ $fragment->appendChild($element->childNodes->item(0));
+ }
+
+ return $fragment;
+ }
+
+ /**
+ * Returns true if `$ancestor` contains the node `$node`.
+ *
+ * @param \DOMNode $ancestor ancestor node
+ * @param \DOMNode $node node
+ * @return boolean true if `$ancestor` contains the node `$node`
+ */
+ public static function contains(\DOMNode $ancestor, \DOMNode $node) {
+ // nodes cannot contain themselves
+ if ($ancestor === $node) {
+ return false;
+ }
+
+ // text nodes cannot contain any other nodes
+ if ($ancestor->nodeType === XML_TEXT_NODE) {
+ return false;
+ }
+
+ $parent = $node;
+ while ($parent = $parent->parentNode) {
+ if ($parent === $ancestor) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the common ancestor of both nodes.
+ *
+ * @param \DOMNode $node1 first node
+ * @param \DOMNode $node2 second node
+ * @return \DOMElement|null common ancestor or null
+ */
+ public static function getCommonAncestor(\DOMNode $node1, \DOMNode $node2) {
+ // abort if both elements share a common element or are both direct descendants
+ // of the same document
+ if ($node1->parentNode === $node2->parentNode) {
+ return $node1->parentNode;
+ }
+
+ // collect the list of all direct ancestors of `$node1`
+ $parents = self::getParents($node1);
+
+ // compare each ancestor of `$node2` to the known list of parents of `$node1`
+ $parent = $node2;
+ while ($parent = $parent->parentNode) {
+ // requires strict type check
+ if (in_array($parent, $parents, true)) {
+ return $parent;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the immediate parent element before provided ancenstor element. Returns null if
+ * the ancestor element is the direct parent of provided node.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMElement $ancestor ancestor node
+ * @return \DOMElement|null immediate parent element before ancestor element
+ */
+ public static function getParentBefore(\DOMNode $node, \DOMElement $ancestor) {
+ if ($node->parentNode === $ancestor) {
+ return null;
+ }
+
+ $parents = self::getParents($node);
+ for ($i = count($parents) - 1; $i >= 0; $i--) {
+ if ($parents[$i] === $ancestor) {
+ return $parents[$i - 1];
+ }
+ }
+
+ throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+ }
+
+ /**
+ * Returns the parent node of given node.
+ *
+ * @param \DOMNode $node node
+ * @return \DOMNode parent node, can be `\DOMElement` or `\DOMDocument`
+ */
+ public static function getParentNode(\DOMNode $node) {
+ return ($node->parentNode) ?: $node->ownerDocument;
+ }
+
+ /**
+ * Returns all ancestors nodes for given node.
+ *
+ * @param \DOMNode $node node
+ * @param boolean $reverseOrder reversing the order causes the most top ancestor to appear first
+ * @return \DOMElement[] list of ancestor nodes
+ */
+ public static function getParents(\DOMNode $node, $reverseOrder = false) {
+ $parents = [];
+
+ $parent = $node;
+ while ($parent = $parent->parentNode) {
+ $parents[] = $parent;
+ }
+
+ return ($reverseOrder) ? array_reverse($parents) : $parents;
+ }
+
+ /**
+ * Determines the relative position of two nodes to each other.
+ *
+ * @param \DOMNode $node1 first node
+ * @param \DOMNode $node2 second node
+ * @return string
+ */
+ public static function getRelativePosition(\DOMNode $node1, \DOMNode $node2) {
+ if ($node1->ownerDocument !== $node2->ownerDocument) {
+ throw new \InvalidArgumentException("Both nodes must be contained in the same DOM document.");
+ }
+
+ $nodeList1 = self::getParents($node1, true);
+ $nodeList1[] = $node1;
+
+ $nodeList2 = self::getParents($node2, true);
+ $nodeList2[] = $node2;
+
+ $commonAncestor = null;
+ $i = 0;
+ while ($nodeList1[$i] === $nodeList2[$i]) {
+ $i++;
+ }
+
+ // check if parent of node 2 appears before parent of node 1
+ $previousSibling = $nodeList1[$i];
+ while ($previousSibling = $previousSibling->previousSibling) {
+ if ($previousSibling === $nodeList2[$i]) {
+ return 'before';
+ }
+ }
+
+ $nextSibling = $nodeList1[$i];
+ while ($nextSibling = $nextSibling->nextSibling) {
+ if ($nextSibling === $nodeList2[$i]) {
+ return 'after';
+ }
+ }
+
+ throw new SystemException("Unable to determine relative node position.");
+ }
+
+ /**
+ * Inserts given DOM node after the reference node.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMNode $refNode reference node
+ */
+ public static function insertAfter(\DOMNode $node, \DOMNode $refNode) {
+ if ($refNode->nextSibling) {
+ self::insertBefore($node, $refNode->nextSibling);
+ }
+ else {
+ self::getParentNode($refNode)->appendChild($node);
+ }
+ }
+
+ /**
+ * Inserts given node before the reference node.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMNode $refNode reference node
+ */
+ public static function insertBefore(\DOMNode $node, \DOMNode $refNode) {
+ self::getParentNode($refNode)->insertBefore($node, $refNode);
+ }
+
+ /**
+ * Returns true if given node is the first node of its given ancestor.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMElement $ancestor ancestor element
+ * @return boolean true if `$node` is the first node of its given ancestor
+ */
+ public static function isFirstNode(\DOMNode $node, \DOMElement $ancestor) {
+ if ($node->previousSibling === null) {
+ if ($node->previousSibling === null) {
+ throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+ }
+ else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+ return true;
+ }
+ else {
+ return self::isFirstNode($node->parentNode, $ancestor);
+ }
+ }
+ else if ($node->parentNode->nodeName === 'body') {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns true if given node is the last node of its given ancestor.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMElement $ancestor ancestor element
+ * @return boolean true if `$node` is the last node of its given ancestor
+ */
+ public static function isLastNode(\DOMNode $node, \DOMElement $ancestor) {
+ if ($node->nextSibling === null) {
+ if ($node->parentNode === null) {
+ throw new \InvalidArgumentException("Provided node is a not a descendant of ancestor element.");
+ }
+ else if ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+ return true;
+ }
+ else {
+ return self::isLastNode($node->parentNode, $ancestor);
+ }
+ }
+ else if ($node->parentNode->nodeName === 'body') {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Moves all nodes into `$container` until it reaches `$lastElement`. The direction
+ * in which nodes will be considered for moving is determined by the logical position
+ * of `$lastElement`.
+ *
+ * @param \DOMElement $container destination element
+ * @param \DOMElement $lastElement last element to move
+ * @param \DOMElement $commonAncestor common ancestor of `$container` and `$lastElement`
+ */
+ public static function moveNodesInto(\DOMElement $container, \DOMElement $lastElement, \DOMElement $commonAncestor) {
+ if (!self::contains($commonAncestor, $container)) {
+ throw new \InvalidArgumentException("The container element must be a child of the common ancestor element.");
+ }
+ else if ($lastElement->parentNode !== $commonAncestor) {
+ throw new \InvalidArgumentException("The last element must be a direct child of the common ancestor element.");
+ }
+
+ $relativePosition = self::getRelativePosition($container, $lastElement);
+
+ // move everything that is logically after `$container` but within
+ // `$commonAncestor` into `$container` until `$lastElement` has been moved
+ $element = $container;
+ do {
+ if ($relativePosition === 'before') {
+ while ($sibling = $element->previousSibling) {
+ self::prepend($sibling, $container);
+ if ($sibling === $lastElement) {
+ return;
+ }
+ }
+ }
+ else {
+ while ($sibling = $element->nextSibling) {
+ $container->appendChild($sibling);
+ if ($sibling === $lastElement) {
+ return;
+ }
+ }
+ }
+
+ $element = $element->parentNode;
+ }
+ while ($element !== $commonAncestor);
+ }
+
+ /**
+ * Prepends a node to provided element.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMElement $element target element
+ */
+ public static function prepend(\DOMNode $node, \DOMElement $element) {
+ if ($element->firstChild === null) {
+ $element->appendChild($node);
+ }
+ else {
+ $element->insertBefore($node, $element->firstChild);
+ }
+ }
+
+ /**
+ * Removes a node, optionally preserves the child nodes if `$node` is an element.
+ *
+ * @param \DOMNode $node target node
+ * @param boolean $preserveChildNodes preserve child nodes, only supported for elements
+ */
+ public static function removeNode(\DOMNode $node, $preserveChildNodes = false) {
+ if ($preserveChildNodes) {
+ if (!($node instanceof \DOMElement)) {
+ throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
+ }
+
+ while ($node->hasChildNodes()) {
+ self::insertBefore($node->childNodes->item(0), $node);
+ }
+ }
+
+ self::getParentNode($node)->removeChild($node);
+ }
+
+ /**
+ * Replaces a DOM element with another, preserving all child nodes by default.
+ *
+ * @param \DOMElement $oldElement old element
+ * @param \DOMElement $newElement new element
+ * @param boolean $preserveChildNodes true if child nodes should be moved, otherwise they'll be implicitly removed
+ */
+ public static function replaceElement(\DOMElement $oldElement, \DOMElement $newElement, $preserveChildNodes = true) {
+ self::insertBefore($newElement, $oldElement);
+
+ // move all child nodes
+ if ($preserveChildNodes) {
+ while ($oldElement->hasChildNodes()) {
+ $newElement->appendChild($oldElement->childNodes->item(0));
+ }
+ }
+
+ // remove old element
+ self::getParentNode($oldElement)->removeChild($oldElement);
+ }
+
+ /**
+ * Splits all parent nodes until `$ancestor` and moved other nodes after/before
+ * (determined by `$splitBefore`) into the newly created nodes. This allows
+ * extraction of DOM parts while preserving nesting for both the extracted nodes
+ * and the remaining siblings.
+ *
+ * @param \DOMNode $node reference node
+ * @param \DOMElement $ancestor ancestor element that should not be split
+ * @param boolean $splitBefore true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
+ * @return \DOMElement parent node containing `$node`, direct child of `$ancestor`
+ */
+ public static function splitParentsUntil(\DOMNode $node, \DOMElement $ancestor, $splitBefore = true) {
+ if (!self::contains($ancestor, $node)) {
+ throw new \InvalidArgumentException("Node is not contained in ancestor node.");
+ }
+
+ // clone the parent node right "below" `$ancestor`
+ $cloneNode = self::getParentBefore($node, $ancestor);
+
+ if ($splitBefore) {
+ if (self::isFirstNode($node, $cloneNode)) {
+ // target node is at the very start, we can safely move the
+ // entire parent node around
+ return $cloneNode;
+ }
+
+ $currentNode = $node;
+ while (($parent = $currentNode->parentNode) !== $ancestor) {
+ $newNode = $parent->cloneNode();
+ self::insertBefore($newNode, $parent);
+
+ while ($currentNode->previousSibling) {
+ $newNode->appendChild($currentNode->previousSibling);
+ }
+
+ $currentNode = $parent;
+ }
+ }
+ else {
+ if (self::isLastNode($node, $cloneNode)) {
+ // target node is at the very end, we can safely move the
+ // entire parent node around
+ return $cloneNode;
+ }
+
+ $currentNode = $node;
+ while (($parent = $currentNode->parentNode) !== $ancestor) {
+ $newNode = $parent->cloneNode();
+ self::insertAfter($newNode, $parent);
+
+ while ($currentNode->nextSibling) {
+ $newNode->appendChild($currentNode->nextSibling);
+ }
+
+ $currentNode = $parent;
+ }
+ }
+
+ return self::getParentBefore($node, $ancestor);
+ }
+
+ private function __construct() { }
+}