/**
* Handles clipboard-related actions.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package WoltLabSuite\Core\System\Clipboard
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\System\Clipboard
*/
-class ClipboardHandler extends SingletonFactory {
- /**
- * cached list of actions
- * @var array
- */
- protected $actionCache;
-
- /**
- * cached list of clipboard item types
- * @var mixed[][]
- */
- protected $cache;
-
- /**
- * list of marked items
- * @var DatabaseObject[][]
- */
- protected $markedItems;
-
- /**
- * cached list of page actions
- * @var array
- */
- protected $pageCache;
-
- /**
- * list of page class names
- * @var string[]
- */
- protected $pageClasses = [];
-
- /**
- * page object id
- * @var integer
- */
- protected $pageObjectID = 0;
-
- /**
- * @inheritDoc
- */
- protected function init() {
- $this->cache = [
- 'objectTypes' => [],
- 'objectTypeNames' => []
- ];
- $cache = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.clipboardItem');
- foreach ($cache as $objectType) {
- $this->cache['objectTypes'][$objectType->objectTypeID] = $objectType;
- $this->cache['objectTypeNames'][$objectType->objectType] = $objectType->objectTypeID;
- }
-
- $this->pageCache = ClipboardPageCacheBuilder::getInstance()->getData();
- }
-
- /**
- * Loads action cache.
- */
- protected function loadActionCache() {
- if ($this->actionCache !== null) return;
-
- $this->actionCache = ClipboardActionCacheBuilder::getInstance()->getData();
- }
-
- /**
- * Marks objects as marked.
- *
- * @param array $objectIDs
- * @param integer $objectTypeID
- */
- public function mark(array $objectIDs, $objectTypeID) {
- // remove existing entries first, prevents conflict with INSERT
- $this->unmark($objectIDs, $objectTypeID);
-
- $sql = "INSERT INTO wcf".WCF_N."_clipboard_item
- (objectTypeID, userID, objectID)
- VALUES (?, ?, ?)";
- $statement = WCF::getDB()->prepareStatement($sql);
- foreach ($objectIDs as $objectID) {
- $statement->execute([
- $objectTypeID,
- WCF::getUser()->userID,
- $objectID
- ]);
- }
- }
-
- /**
- * Removes an object marking.
- *
- * @param array $objectIDs
- * @param integer $objectTypeID
- */
- public function unmark(array $objectIDs, $objectTypeID) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("objectTypeID = ?", [$objectTypeID]);
- $conditions->add("objectID IN (?)", [$objectIDs]);
- $conditions->add("userID = ?", [WCF::getUser()->userID]);
-
- $sql = "DELETE FROM wcf".WCF_N."_clipboard_item
- ".$conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditions->getParameters());
- }
-
- /**
- * Unmarks all items of given type.
- *
- * @param integer $objectTypeID
- */
- public function unmarkAll($objectTypeID) {
- $sql = "DELETE FROM wcf".WCF_N."_clipboard_item
- WHERE objectTypeID = ?
- AND userID = ?";
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute([
- $objectTypeID,
- WCF::getUser()->userID
- ]);
- }
-
- /**
- * Returns the id of the clipboard object type with the given name or `null` if no such
- * clipboard object type exists.
- *
- * @param string $typeName
- * @return integer|null
- */
- public function getObjectTypeID($typeName) {
- if (isset($this->cache['objectTypeNames'][$typeName])) {
- return $this->cache['objectTypeNames'][$typeName];
- }
-
- return null;
- }
-
- /**
- * Returns the clipboard object type with the given id or `null` if no such
- * clipboard object type exists.
- *
- * @param integer $objectTypeID
- * @return ObjectType|null
- */
- public function getObjectType($objectTypeID) {
- if (isset($this->cache['objectTypes'][$objectTypeID])) {
- return $this->cache['objectTypes'][$objectTypeID];
- }
-
- return null;
- }
-
- /**
- * Returns the id of the clipboard object type with the given name or `null` if no such
- * clipboard object type exists.
- *
- * @param string $objectType
- * @return integer|null
- */
- public function getObjectTypeByName($objectType) {
- foreach ($this->cache['objectTypes'] as $objectTypeID => $objectTypeObj) {
- if ($objectTypeObj->objectType == $objectType) {
- return $objectTypeID;
- }
- }
-
- return null;
- }
-
- /**
- * Loads a list of marked items grouped by type name.
- *
- * @param integer $objectTypeID
- * @throws SystemException
- */
- protected function loadMarkedItems($objectTypeID = null) {
- if ($this->markedItems === null) {
- $this->markedItems = [];
- }
-
- if ($objectTypeID !== null) {
- $objectType = $this->getObjectType($objectTypeID);
- if ($objectType === null) {
- throw new SystemException("object type id ".$objectTypeID." is invalid");
- }
-
- if (!isset($this->markedItems[$objectType->objectType])) {
- $this->markedItems[$objectType->objectType] = [];
- }
- }
-
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID = ?", [WCF::getUser()->userID]);
- if ($objectTypeID !== null) {
- $conditions->add("objectTypeID = ?", [$objectTypeID]);
- }
-
- // fetch object ids
- $sql = "SELECT objectTypeID, objectID
- FROM wcf".WCF_N."_clipboard_item
- ".$conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditions->getParameters());
-
- // group object ids by type name
- $data = [];
- while ($row = $statement->fetchArray()) {
- $objectType = $this->getObjectType($row['objectTypeID']);
- if ($objectType === null) {
- continue;
- }
-
- if (!isset($data[$objectType->objectType])) {
- /** @noinspection PhpUndefinedFieldInspection */
- $listClassName = $objectType->listclassname;
- if ($listClassName == '') {
- throw new SystemException("Missing list class for object type '".$objectType->objectType."'");
- }
-
- $data[$objectType->objectType] = [
- 'className' => $listClassName,
- 'objectIDs' => []
- ];
- }
-
- $data[$objectType->objectType]['objectIDs'][] = $row['objectID'];
- }
-
- // read objects
- foreach ($data as $objectType => $objectData) {
- /** @var DatabaseObjectList $objectList */
- $objectList = new $objectData['className']();
- $objectList->getConditionBuilder()->add($objectList->getDatabaseTableAlias() . "." . $objectList->getDatabaseTableIndexName() . " IN (?)", [$objectData['objectIDs']]);
- $objectList->readObjects();
-
- $this->markedItems[$objectType] = $objectList->getObjects();
-
- // validate object ids against loaded items (check for zombie object ids)
- $indexName = $objectList->getDatabaseTableIndexName();
- foreach ($this->markedItems[$objectType] as $object) {
- /** @noinspection PhpVariableVariableInspection */
- $index = array_search($object->$indexName, $objectData['objectIDs']);
- unset($objectData['objectIDs'][$index]);
- }
-
- if (!empty($objectData['objectIDs'])) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("objectTypeID = ?", [$this->getObjectTypeByName($objectType)]);
- $conditions->add("userID = ?", [WCF::getUser()->userID]);
- $conditions->add("objectID IN (?)", [$objectData['objectIDs']]);
-
- $sql = "DELETE FROM wcf".WCF_N."_clipboard_item
- ".$conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditions->getParameters());
- }
- }
- }
-
- /**
- * Loads a list of marked items grouped by type name.
- *
- * @param integer $objectTypeID
- * @return array
- */
- public function getMarkedItems($objectTypeID = null) {
- if ($this->markedItems === null) {
- $this->loadMarkedItems($objectTypeID);
- }
-
- if ($objectTypeID !== null) {
- $objectType = $this->getObjectType($objectTypeID);
- if (!isset($this->markedItems[$objectType->objectType])) {
- $this->loadMarkedItems($objectTypeID);
- }
-
- return $this->markedItems[$objectType->objectType];
- }
-
- return $this->markedItems;
- }
-
- /**
- * Returns the data of the items for clipboard editor or `null` if no items are marked.
- *
- * @param string|string[] $page
- * @param integer $pageObjectID
- * @return array|null
- * @throws ImplementationException
- */
- public function getEditorItems($page, $pageObjectID) {
- $pages = $page;
- if (!is_array($pages)) {
- $pages = [$page];
- }
-
- $this->pageClasses = [];
- $this->pageObjectID = 0;
-
- // get objects
- $this->loadMarkedItems();
- if (empty($this->markedItems)) return null;
-
- $this->pageClasses = $pages;
- $this->pageObjectID = $pageObjectID;
-
- // fetch action ids
- $this->loadActionCache();
- $actionIDs = [];
- foreach ($pages as $page) {
- foreach ($this->pageCache[$page] as $actionID) {
- if (isset($this->actionCache[$actionID])) {
- $actionIDs[] = $actionID;
- }
- }
- }
- $actionIDs = array_unique($actionIDs);
-
- // load actions
- $actions = [];
- foreach ($actionIDs as $actionID) {
- $actionObject = $this->actionCache[$actionID];
- $actionClassName = $actionObject->actionClassName;
- if (!isset($actions[$actionClassName])) {
- // validate class
- if (!is_subclass_of($actionClassName, IClipboardAction::class)) {
- throw new ImplementationException($actionClassName, IClipboardAction::class);
- }
-
- $actions[$actionClassName] = [
- 'actions' => [],
- 'object' => new $actionClassName()
- ];
- }
-
- $actions[$actionClassName]['actions'][] = $actionObject;
- }
-
- // execute actions
- $editorData = [];
- foreach ($actions as $actionData) {
- /** @var IClipboardAction $clipboardAction */
- $clipboardAction = $actionData['object'];
-
- // get accepted objects
- $typeName = $clipboardAction->getTypeName();
- if (!isset($this->markedItems[$typeName]) || empty($this->markedItems[$typeName])) continue;
-
- if (!isset($editorData[$typeName])) {
- $editorData[$typeName] = [
- 'label' => $clipboardAction->getEditorLabel($this->markedItems[$typeName]),
- 'items' => [],
- 'reloadPageOnSuccess' => $clipboardAction->getReloadPageOnSuccess()
- ];
- }
- else {
- $editorData[$typeName]['reloadPageOnSuccess'] = array_unique(array_merge(
- $editorData[$typeName]['reloadPageOnSuccess'],
- $clipboardAction->getReloadPageOnSuccess()
- ));
- }
-
- foreach ($actionData['actions'] as $actionObject) {
- $data = $clipboardAction->execute($this->markedItems[$typeName], $actionObject);
- if ($data === null) {
- continue;
- }
-
- $editorData[$typeName]['items'][$actionObject->showOrder] = $data;
- }
- }
-
- return $editorData;
- }
-
- /**
- * Removes items from clipboard.
- *
- * @param integer $typeID
- */
- public function removeItems($typeID = null) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add("userID = ?", [WCF::getUser()->userID]);
- if ($typeID !== null) $conditions->add("objectTypeID = ?", [$typeID]);
-
- $sql = "DELETE FROM wcf".WCF_N."_clipboard_item
- ".$conditions;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditions->getParameters());
- }
-
- /**
- * Returns true (1) if at least one item (of the given object type) is marked.
- *
- * @param integer $objectTypeID
- * @return integer
- */
- public function hasMarkedItems($objectTypeID = null) {
- if (!WCF::getUser()->userID) return 0;
-
- $conditionBuilder = new PreparedStatementConditionBuilder();
- $conditionBuilder->add("userID = ?", [WCF::getUser()->userID]);
- if ($objectTypeID !== null) {
- $conditionBuilder->add("objectTypeID = ?", [$objectTypeID]);
- }
-
- $sql = "SELECT COUNT(*)
- FROM wcf".WCF_N."_clipboard_item
- ".$conditionBuilder;
- $statement = WCF::getDB()->prepareStatement($sql);
- $statement->execute($conditionBuilder->getParameters());
-
- return $statement->fetchSingleColumn() ? 1 : 0;
- }
-
- /**
- * Returns the list of page class names.
- *
- * @return string[]
- */
- public function getPageClasses() {
- return $this->pageClasses;
- }
-
- /**
- * Returns page object id.
- *
- * @return integer
- */
- public function getPageObjectID() {
- return $this->pageObjectID;
- }
+class ClipboardHandler extends SingletonFactory
+{
+ /**
+ * cached list of actions
+ * @var array
+ */
+ protected $actionCache;
+
+ /**
+ * cached list of clipboard item types
+ * @var mixed[][]
+ */
+ protected $cache;
+
+ /**
+ * list of marked items
+ * @var DatabaseObject[][]
+ */
+ protected $markedItems;
+
+ /**
+ * cached list of page actions
+ * @var array
+ */
+ protected $pageCache;
+
+ /**
+ * list of page class names
+ * @var string[]
+ */
+ protected $pageClasses = [];
+
+ /**
+ * page object id
+ * @var int
+ */
+ protected $pageObjectID = 0;
+
+ /**
+ * @inheritDoc
+ */
+ protected function init()
+ {
+ $this->cache = [
+ 'objectTypes' => [],
+ 'objectTypeNames' => [],
+ ];
+ $cache = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.clipboardItem');
+ foreach ($cache as $objectType) {
+ $this->cache['objectTypes'][$objectType->objectTypeID] = $objectType;
+ $this->cache['objectTypeNames'][$objectType->objectType] = $objectType->objectTypeID;
+ }
+
+ $this->pageCache = ClipboardPageCacheBuilder::getInstance()->getData();
+ }
+
+ /**
+ * Loads action cache.
+ */
+ protected function loadActionCache()
+ {
+ if ($this->actionCache !== null) {
+ return;
+ }
+
+ $this->actionCache = ClipboardActionCacheBuilder::getInstance()->getData();
+ }
+
+ /**
+ * Marks objects as marked.
+ *
+ * @param array $objectIDs
+ * @param int $objectTypeID
+ */
+ public function mark(array $objectIDs, $objectTypeID)
+ {
+ // remove existing entries first, prevents conflict with INSERT
+ $this->unmark($objectIDs, $objectTypeID);
+
+ $sql = "INSERT INTO wcf" . WCF_N . "_clipboard_item
+ (objectTypeID, userID, objectID)
+ VALUES (?, ?, ?)";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ foreach ($objectIDs as $objectID) {
+ $statement->execute([
+ $objectTypeID,
+ WCF::getUser()->userID,
+ $objectID,
+ ]);
+ }
+ }
+
+ /**
+ * Removes an object marking.
+ *
+ * @param array $objectIDs
+ * @param int $objectTypeID
+ */
+ public function unmark(array $objectIDs, $objectTypeID)
+ {
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("objectTypeID = ?", [$objectTypeID]);
+ $conditions->add("objectID IN (?)", [$objectIDs]);
+ $conditions->add("userID = ?", [WCF::getUser()->userID]);
+
+ $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
+ " . $conditions;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditions->getParameters());
+ }
+
+ /**
+ * Unmarks all items of given type.
+ *
+ * @param int $objectTypeID
+ */
+ public function unmarkAll($objectTypeID)
+ {
+ $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
+ WHERE objectTypeID = ?
+ AND userID = ?";
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute([
+ $objectTypeID,
+ WCF::getUser()->userID,
+ ]);
+ }
+
+ /**
+ * Returns the id of the clipboard object type with the given name or `null` if no such
+ * clipboard object type exists.
+ *
+ * @param string $typeName
+ * @return int|null
+ */
+ public function getObjectTypeID($typeName)
+ {
+ if (isset($this->cache['objectTypeNames'][$typeName])) {
+ return $this->cache['objectTypeNames'][$typeName];
+ }
+ }
+
+ /**
+ * Returns the clipboard object type with the given id or `null` if no such
+ * clipboard object type exists.
+ *
+ * @param int $objectTypeID
+ * @return ObjectType|null
+ */
+ public function getObjectType($objectTypeID)
+ {
+ if (isset($this->cache['objectTypes'][$objectTypeID])) {
+ return $this->cache['objectTypes'][$objectTypeID];
+ }
+ }
+
+ /**
+ * Returns the id of the clipboard object type with the given name or `null` if no such
+ * clipboard object type exists.
+ *
+ * @param string $objectType
+ * @return int|null
+ */
+ public function getObjectTypeByName($objectType)
+ {
+ foreach ($this->cache['objectTypes'] as $objectTypeID => $objectTypeObj) {
+ if ($objectTypeObj->objectType == $objectType) {
+ return $objectTypeID;
+ }
+ }
+ }
+
+ /**
+ * Loads a list of marked items grouped by type name.
+ *
+ * @param int $objectTypeID
+ * @throws SystemException
+ */
+ protected function loadMarkedItems($objectTypeID = null)
+ {
+ if ($this->markedItems === null) {
+ $this->markedItems = [];
+ }
+
+ if ($objectTypeID !== null) {
+ $objectType = $this->getObjectType($objectTypeID);
+ if ($objectType === null) {
+ throw new SystemException("object type id " . $objectTypeID . " is invalid");
+ }
+
+ if (!isset($this->markedItems[$objectType->objectType])) {
+ $this->markedItems[$objectType->objectType] = [];
+ }
+ }
+
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("userID = ?", [WCF::getUser()->userID]);
+ if ($objectTypeID !== null) {
+ $conditions->add("objectTypeID = ?", [$objectTypeID]);
+ }
+
+ // fetch object ids
+ $sql = "SELECT objectTypeID, objectID
+ FROM wcf" . WCF_N . "_clipboard_item
+ " . $conditions;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditions->getParameters());
+
+ // group object ids by type name
+ $data = [];
+ while ($row = $statement->fetchArray()) {
+ $objectType = $this->getObjectType($row['objectTypeID']);
+ if ($objectType === null) {
+ continue;
+ }
+
+ if (!isset($data[$objectType->objectType])) {
+ /** @noinspection PhpUndefinedFieldInspection */
+ $listClassName = $objectType->listclassname;
+ if ($listClassName == '') {
+ throw new SystemException("Missing list class for object type '" . $objectType->objectType . "'");
+ }
+
+ $data[$objectType->objectType] = [
+ 'className' => $listClassName,
+ 'objectIDs' => [],
+ ];
+ }
+
+ $data[$objectType->objectType]['objectIDs'][] = $row['objectID'];
+ }
+
+ // read objects
+ foreach ($data as $objectType => $objectData) {
+ /** @var DatabaseObjectList $objectList */
+ $objectList = new $objectData['className']();
+ $objectList->getConditionBuilder()->add(
+ $objectList->getDatabaseTableAlias() . "." . $objectList->getDatabaseTableIndexName() . " IN (?)",
+ [$objectData['objectIDs']]
+ );
+ $objectList->readObjects();
+
+ $this->markedItems[$objectType] = $objectList->getObjects();
+
+ // validate object ids against loaded items (check for zombie object ids)
+ $indexName = $objectList->getDatabaseTableIndexName();
+ foreach ($this->markedItems[$objectType] as $object) {
+ /** @noinspection PhpVariableVariableInspection */
+ $index = \array_search($object->{$indexName}, $objectData['objectIDs']);
+ unset($objectData['objectIDs'][$index]);
+ }
+
+ if (!empty($objectData['objectIDs'])) {
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("objectTypeID = ?", [$this->getObjectTypeByName($objectType)]);
+ $conditions->add("userID = ?", [WCF::getUser()->userID]);
+ $conditions->add("objectID IN (?)", [$objectData['objectIDs']]);
+
+ $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
+ " . $conditions;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditions->getParameters());
+ }
+ }
+ }
+
+ /**
+ * Loads a list of marked items grouped by type name.
+ *
+ * @param int $objectTypeID
+ * @return array
+ */
+ public function getMarkedItems($objectTypeID = null)
+ {
+ if ($this->markedItems === null) {
+ $this->loadMarkedItems($objectTypeID);
+ }
+
+ if ($objectTypeID !== null) {
+ $objectType = $this->getObjectType($objectTypeID);
+ if (!isset($this->markedItems[$objectType->objectType])) {
+ $this->loadMarkedItems($objectTypeID);
+ }
+
+ return $this->markedItems[$objectType->objectType];
+ }
+
+ return $this->markedItems;
+ }
+
+ /**
+ * Returns the data of the items for clipboard editor or `null` if no items are marked.
+ *
+ * @param string|string[] $page
+ * @param int $pageObjectID
+ * @return array|null
+ * @throws ImplementationException
+ */
+ public function getEditorItems($page, $pageObjectID)
+ {
+ $pages = $page;
+ if (!\is_array($pages)) {
+ $pages = [$page];
+ }
+
+ $this->pageClasses = [];
+ $this->pageObjectID = 0;
+
+ // get objects
+ $this->loadMarkedItems();
+ if (empty($this->markedItems)) {
+ return;
+ }
+
+ $this->pageClasses = $pages;
+ $this->pageObjectID = $pageObjectID;
+
+ // fetch action ids
+ $this->loadActionCache();
+ $actionIDs = [];
+ foreach ($pages as $page) {
+ foreach ($this->pageCache[$page] as $actionID) {
+ if (isset($this->actionCache[$actionID])) {
+ $actionIDs[] = $actionID;
+ }
+ }
+ }
+ $actionIDs = \array_unique($actionIDs);
+
+ // load actions
+ $actions = [];
+ foreach ($actionIDs as $actionID) {
+ $actionObject = $this->actionCache[$actionID];
+ $actionClassName = $actionObject->actionClassName;
+ if (!isset($actions[$actionClassName])) {
+ // validate class
+ if (!\is_subclass_of($actionClassName, IClipboardAction::class)) {
+ throw new ImplementationException($actionClassName, IClipboardAction::class);
+ }
+
+ $actions[$actionClassName] = [
+ 'actions' => [],
+ 'object' => new $actionClassName(),
+ ];
+ }
+
+ $actions[$actionClassName]['actions'][] = $actionObject;
+ }
+
+ // execute actions
+ $editorData = [];
+ foreach ($actions as $actionData) {
+ /** @var IClipboardAction $clipboardAction */
+ $clipboardAction = $actionData['object'];
+
+ // get accepted objects
+ $typeName = $clipboardAction->getTypeName();
+ if (!isset($this->markedItems[$typeName]) || empty($this->markedItems[$typeName])) {
+ continue;
+ }
+
+ if (!isset($editorData[$typeName])) {
+ $editorData[$typeName] = [
+ 'label' => $clipboardAction->getEditorLabel($this->markedItems[$typeName]),
+ 'items' => [],
+ 'reloadPageOnSuccess' => $clipboardAction->getReloadPageOnSuccess(),
+ ];
++ } else {
++ $editorData[$typeName]['reloadPageOnSuccess'] = array_unique(array_merge(
++ $editorData[$typeName]['reloadPageOnSuccess'],
++ $clipboardAction->getReloadPageOnSuccess()
++ ));
+ }
+
+ foreach ($actionData['actions'] as $actionObject) {
+ $data = $clipboardAction->execute($this->markedItems[$typeName], $actionObject);
+ if ($data === null) {
+ continue;
+ }
+
+ $editorData[$typeName]['items'][$actionObject->showOrder] = $data;
+ }
+ }
+
+ return $editorData;
+ }
+
+ /**
+ * Removes items from clipboard.
+ *
+ * @param int $typeID
+ */
+ public function removeItems($typeID = null)
+ {
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add("userID = ?", [WCF::getUser()->userID]);
+ if ($typeID !== null) {
+ $conditions->add("objectTypeID = ?", [$typeID]);
+ }
+
+ $sql = "DELETE FROM wcf" . WCF_N . "_clipboard_item
+ " . $conditions;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditions->getParameters());
+ }
+
+ /**
+ * Returns true (1) if at least one item (of the given object type) is marked.
+ *
+ * @param int $objectTypeID
+ * @return int
+ */
+ public function hasMarkedItems($objectTypeID = null)
+ {
+ if (!WCF::getUser()->userID) {
+ return 0;
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add("userID = ?", [WCF::getUser()->userID]);
+ if ($objectTypeID !== null) {
+ $conditionBuilder->add("objectTypeID = ?", [$objectTypeID]);
+ }
+
+ $sql = "SELECT COUNT(*)
+ FROM wcf" . WCF_N . "_clipboard_item
+ " . $conditionBuilder;
+ $statement = WCF::getDB()->prepareStatement($sql);
+ $statement->execute($conditionBuilder->getParameters());
+
+ return $statement->fetchSingleColumn() ? 1 : 0;
+ }
+
+ /**
+ * Returns the list of page class names.
+ *
+ * @return string[]
+ */
+ public function getPageClasses()
+ {
+ return $this->pageClasses;
+ }
+
+ /**
+ * Returns page object id.
+ *
+ * @return int
+ */
+ public function getPageObjectID()
+ {
+ return $this->pageObjectID;
+ }
}
* @package WoltLabSuite\Core\System\Html\Node
* @since 3.0
*/
-abstract class AbstractHtmlNodeProcessor implements IHtmlNodeProcessor {
- /**
- * active DOM document
- * @var \DOMDocument
- */
- protected $document;
-
- /**
- * html processor instance
- * @var IHtmlProcessor
- */
- protected $htmlProcessor;
-
- /**
- * required interface for html nodes
- * @var string
- */
- protected $nodeInterface = '';
-
- /**
- * storage for node replacements
- * @var array
- */
- protected $nodeData = [];
-
- /**
- * XPath instance
- * @var \DOMXPath
- */
- protected $xpath;
-
- /**
- * @inheritDoc
- */
- public function load(IHtmlProcessor $htmlProcessor, $html) {
- $this->htmlProcessor = $htmlProcessor;
-
- $this->document = new \DOMDocument('1.0', 'UTF-8');
- $this->xpath = null;
-
- $html = preg_replace_callback('~(<pre[^>]*>)(.*?)(</pre>)~s', function($matches) {
- return $matches[1] . preg_replace('~\r?\n~', '@@@WCF_PRE_LINEBREAK@@@', $matches[2]) . $matches[3];
- }, $html);
-
- // strip UTF-8 zero-width whitespace
- $html = preg_replace('~\x{200B}~u', '', $html);
-
- // discard any non-breaking spaces
- $html = str_replace(' ', ' ', $html);
-
- // work-around for a libxml bug that causes a single space between
- // some inline elements to be dropped
- $html = str_replace('> <', '> <', $html);
-
- // Ignore all errors when loading the HTML string, because DOMDocument does not
- // 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.
- //
- // We're also injecting a bogus meta tag that magically enables DOMDocument
- // to handle UTF-8 properly. This avoids encoding non-ASCII characters as it
- // would conflict with already existing entities when reverting them.
- @$this->document->loadHTML('<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $html . '</body></html>');
-
- // flush libxml's error buffer, after all we don't care for any errors caused
- // by the `loadHTML()` call above anyway
- libxml_clear_errors();
-
- // fix the `<pre>` linebreaks again
- $pres = $this->document->getElementsByTagName('pre');
- for ($i = 0, $length = $pres->length; $i < $length; $i++) {
- /** @var \DOMElement $pre */
- $pre = $pres->item($i);
- /** @var \DOMNode $node */
- foreach ($pre->childNodes as $node) {
- if ($node->nodeType === XML_TEXT_NODE && mb_strpos($node->textContent, '@@@WCF_PRE_LINEBREAK@@@') !== false) {
- $node->nodeValue = str_replace('@@@WCF_PRE_LINEBREAK@@@', "\n", $node->textContent);
- }
- }
- }
-
- $this->nodeData = [];
- }
-
- /**
- * @inheritDoc
- */
- public function getHtml() {
- $html = $this->document->saveHTML($this->document->getElementsByTagName('body')->item(0));
-
- // remove nuisance added by PHP
- $html = preg_replace('~^<!DOCTYPE[^>]+>\n~', '', $html);
- $html = preg_replace('~^<body>~', '', $html);
- $html = preg_replace('~</body>$~', '', $html);
-
- foreach ($this->nodeData as $data) {
- $html = preg_replace_callback('~<wcfNode-' . $data['identifier'] . '>(?P<content>[\s\S]*)</wcfNode-' . $data['identifier'] . '>~', function($matches) use ($data) {
- /** @var IHtmlNode $obj */
- $obj = $data['object'];
- $string = $obj->replaceTag($data['data']);
-
- if (!isset($data['data']['skipInnerContent']) || $data['data']['skipInnerContent'] !== true) {
- if (mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
- return str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
- }
- else {
- if (mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
- return str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
- }
- }
- }
-
- return $string;
- }, $html);
-
- }
-
- // work-around for a libxml bug that causes a single space between
- // some inline elements to be dropped
- $html = str_replace(' ', ' ', $html);
- $html = preg_replace('~>\x{00A0}<~u', '> <', $html);
-
- return $html;
- }
-
- /**
- * @inheritDoc
- */
- public function getDocument() {
- return $this->document;
- }
-
- /**
- * Returns a XPath instance for the current DOM document.
- *
- * @return \DOMXPath XPath instance
- */
- public function getXPath() {
- if ($this->xpath === null) {
- $this->xpath = new \DOMXPath($this->getDocument());
- }
-
- return $this->xpath;
- }
-
- /**
- * Renames a tag by creating a new element, moving all child nodes and
- * eventually removing the original element.
- *
- * @param \DOMElement $element old element
- * @param string $tagName tag name for the new element
- * @param bool $preserveAttributes retain attributes for the new element
- * @return \DOMElement newly created element
- */
- public function renameTag(\DOMElement $element, $tagName, $preserveAttributes = false) {
- $newElement = $this->document->createElement($tagName);
- if ($preserveAttributes) {
- /** @var \DOMNode $attribute */
- foreach ($element->attributes as $attribute) {
- $newElement->setAttribute($attribute->nodeName, $attribute->nodeValue);
- }
- }
-
- $element->parentNode->insertBefore($newElement, $element);
- while ($element->hasChildNodes()) {
- $newElement->appendChild($element->firstChild);
- }
-
- $element->parentNode->removeChild($element);
-
- return $newElement;
- }
-
- /**
- * Replaces an element with plain text.
- *
- * @param \DOMElement $element target element
- * @param string $text text used to replace target
- * @param boolean $isBlockElement true if element is a block element
- */
- public function replaceElementWithText(\DOMElement $element, $text, $isBlockElement) {
- $textNode = $element->ownerDocument->createTextNode($text);
- $element->parentNode->insertBefore($textNode, $element);
-
- if ($isBlockElement) {
- for ($i = 0; $i < 2; $i++) {
- $br = $element->ownerDocument->createElement('br');
- $element->parentNode->insertBefore($br, $element);
- }
- }
-
- $element->parentNode->removeChild($element);
- }
-
- /**
- * Removes an element but preserves child nodes by moving them into
- * its original position.
- *
- * @param \DOMElement $element element to be removed
- */
- public function unwrapContent(\DOMElement $element) {
- while ($element->hasChildNodes()) {
- $element->parentNode->insertBefore($element->firstChild, $element);
- }
-
- $element->parentNode->removeChild($element);
- }
-
- /**
- * Adds node replacement data.
- *
- * @param IHtmlNode $htmlNode node processor instance
- * @param string $nodeIdentifier replacement node identifier
- * @param array $data replacement data
- */
- public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data) {
- $this->nodeData[] = [
- 'data' => $data,
- 'identifier' => $nodeIdentifier,
- 'object' => $htmlNode,
- ];
- }
-
- /**
- * Parses an attribute string.
- *
- * @param string $attributes base64 and JSON encoded attributes
- * @return array parsed attributes
- */
- public function parseAttributes($attributes) {
- if (empty($attributes)) {
- return [];
- }
-
- $parsedAttributes = base64_decode($attributes, true);
- if ($parsedAttributes !== false) {
- try {
- $parsedAttributes = JSON::decode($parsedAttributes);
- }
- catch (SystemException $e) {
- /* parse errors can occur if user provided malicious content - ignore them */
- $parsedAttributes = [];
- }
- }
-
- return $parsedAttributes;
- }
-
- /**
- * @inheritDoc
- */
- public function getHtmlProcessor() {
- return $this->htmlProcessor;
- }
-
- /**
- * Invokes a html node processor.
- *
- * @param IHtmlNode $htmlNode html node processor
- */
- protected function invokeHtmlNode(IHtmlNode $htmlNode) {
- if (!($htmlNode instanceof $this->nodeInterface)) {
- throw new \InvalidArgumentException("Node '" . get_class($htmlNode) . "' does not implement the interface '" . $this->nodeInterface . "'.");
- }
-
- $tagName = $htmlNode->getTagName();
- if (empty($tagName)) {
- throw new \UnexpectedValueException("Missing tag name for " . get_class($htmlNode));
- }
-
- $elements = [];
- foreach ($this->getXPath()->query("//{$tagName}") as $element) {
- $elements[] = $element;
- }
-
- if (!empty($elements)) {
- $htmlNode->process($elements, $this);
- }
- }
-
- /**
- * Invokes possible html node processors based on found element tag names.
- *
- * @param string $classNamePattern full namespace pattern for class guessing
- * @param string[] $skipTags list of tag names that should be ignored
- * @param callable $callback optional callback
- */
- protected function invokeNodeHandlers($classNamePattern, array $skipTags = [], callable $callback = null) {
- $skipTags = array_merge($skipTags, ['html', 'head', 'title', 'meta', 'body', 'link']);
-
- $tags = [];
- /** @var \DOMElement $tag */
- foreach ($this->getXPath()->query("//*") as $tag) {
- $tagName = $tag->nodeName;
- if (!isset($tags[$tagName])) $tags[$tagName] = $tagName;
- }
-
- foreach ($tags as $tagName) {
- if (in_array($tagName, $skipTags)) {
- continue;
- }
-
- $tagName = preg_replace_callback('/-([a-z])/', function($matches) {
- return ucfirst($matches[1]);
- }, $tagName);
- $className = $classNamePattern . ucfirst($tagName);
- if (class_exists($className)) {
- if ($callback === null) {
- $this->invokeHtmlNode(new $className);
- }
- else {
- $callback(new $className);
- }
- }
- }
- }
+abstract class AbstractHtmlNodeProcessor implements IHtmlNodeProcessor
+{
+ /**
+ * active DOM document
+ * @var \DOMDocument
+ */
+ protected $document;
+
+ /**
+ * html processor instance
+ * @var IHtmlProcessor
+ */
+ protected $htmlProcessor;
+
+ /**
+ * required interface for html nodes
+ * @var string
+ */
+ protected $nodeInterface = '';
+
+ /**
+ * storage for node replacements
+ * @var array
+ */
+ protected $nodeData = [];
+
+ /**
+ * XPath instance
+ * @var \DOMXPath
+ */
+ protected $xpath;
+
+ /**
+ * @inheritDoc
+ */
+ public function load(IHtmlProcessor $htmlProcessor, $html)
+ {
+ $this->htmlProcessor = $htmlProcessor;
+
+ $this->document = new \DOMDocument('1.0', 'UTF-8');
+ $this->xpath = null;
+
+ $html = \preg_replace_callback('~(<pre[^>]*>)(.*?)(</pre>)~s', static function ($matches) {
+ return $matches[1] . \preg_replace('~\r?\n~', '@@@WCF_PRE_LINEBREAK@@@', $matches[2]) . $matches[3];
+ }, $html);
+
+ // strip UTF-8 zero-width whitespace
+ $html = \preg_replace('~\x{200B}~u', '', $html);
+
+ // discard any non-breaking spaces
+ $html = \str_replace(' ', ' ', $html);
+
+ // work-around for a libxml bug that causes a single space between
+ // some inline elements to be dropped
+ $html = \str_replace('> <', '> <', $html);
+
+ // Ignore all errors when loading the HTML string, because DOMDocument does not
+ // 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.
+ //
+ // We're also injecting a bogus meta tag that magically enables DOMDocument
+ // to handle UTF-8 properly. This avoids encoding non-ASCII characters as it
+ // would conflict with already existing entities when reverting them.
+ @$this->document->loadHTML(
+ '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $html . '</body></html>'
+ );
+
+ // flush libxml's error buffer, after all we don't care for any errors caused
+ // by the `loadHTML()` call above anyway
+ \libxml_clear_errors();
+
+ // fix the `<pre>` linebreaks again
+ $pres = $this->document->getElementsByTagName('pre');
+ for ($i = 0, $length = $pres->length; $i < $length; $i++) {
+ /** @var \DOMElement $pre */
+ $pre = $pres->item($i);
+ /** @var \DOMNode $node */
+ foreach ($pre->childNodes as $node) {
+ if (
+ $node->nodeType === \XML_TEXT_NODE
+ && \mb_strpos($node->textContent, '@@@WCF_PRE_LINEBREAK@@@') !== false
+ ) {
+ $node->nodeValue = \str_replace('@@@WCF_PRE_LINEBREAK@@@', "\n", $node->textContent);
+ }
+ }
+ }
+
+ $this->nodeData = [];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getHtml()
+ {
+ $html = $this->document->saveHTML($this->document->getElementsByTagName('body')->item(0));
+
+ // remove nuisance added by PHP
+ $html = \preg_replace('~^<!DOCTYPE[^>]+>\n~', '', $html);
+ $html = \preg_replace('~^<body>~', '', $html);
+ $html = \preg_replace('~</body>$~', '', $html);
+
+ foreach ($this->nodeData as $data) {
+ $html = \preg_replace_callback(
+ '~<wcfNode-' . $data['identifier'] . '>(?P<content>[\s\S]*)</wcfNode-' . $data['identifier'] . '>~',
+ static function ($matches) use ($data) {
+ /** @var IHtmlNode $obj */
+ $obj = $data['object'];
+ $string = $obj->replaceTag($data['data']);
+
+ if (!isset($data['data']['skipInnerContent']) || $data['data']['skipInnerContent'] !== true) {
+ if (\mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
+ return \str_replace('<!-- META_CODE_INNER_CONTENT -->', $matches['content'], $string);
+ } else {
+ if (\mb_strpos($string, '<!-- META_CODE_INNER_CONTENT -->') !== false) {
+ return \str_replace(
+ '<!-- META_CODE_INNER_CONTENT -->',
+ $matches['content'],
+ $string
+ );
+ }
+ }
+ }
+
+ return $string;
+ },
+ $html
+ );
+ }
+
+ // work-around for a libxml bug that causes a single space between
+ // some inline elements to be dropped
+ $html = \str_replace(' ', ' ', $html);
+ $html = \preg_replace('~>\x{00A0}<~u', '> <', $html);
+
+ return $html;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDocument()
+ {
+ return $this->document;
+ }
+
+ /**
+ * Returns a XPath instance for the current DOM document.
+ *
+ * @return \DOMXPath XPath instance
+ */
+ public function getXPath()
+ {
+ if ($this->xpath === null) {
+ $this->xpath = new \DOMXPath($this->getDocument());
+ }
+
+ return $this->xpath;
+ }
+
+ /**
+ * Renames a tag by creating a new element, moving all child nodes and
+ * eventually removing the original element.
+ *
+ * @param \DOMElement $element old element
+ * @param string $tagName tag name for the new element
+ * @param bool $preserveAttributes retain attributes for the new element
+ * @return \DOMElement newly created element
+ */
+ public function renameTag(\DOMElement $element, $tagName, $preserveAttributes = false)
+ {
+ $newElement = $this->document->createElement($tagName);
+ if ($preserveAttributes) {
+ /** @var \DOMNode $attribute */
+ foreach ($element->attributes as $attribute) {
+ $newElement->setAttribute($attribute->nodeName, $attribute->nodeValue);
+ }
+ }
+
+ $element->parentNode->insertBefore($newElement, $element);
+ while ($element->hasChildNodes()) {
+ $newElement->appendChild($element->firstChild);
+ }
+
+ $element->parentNode->removeChild($element);
+
+ return $newElement;
+ }
+
+ /**
+ * Replaces an element with plain text.
+ *
+ * @param \DOMElement $element target element
+ * @param string $text text used to replace target
+ * @param bool $isBlockElement true if element is a block element
+ */
+ public function replaceElementWithText(\DOMElement $element, $text, $isBlockElement)
+ {
+ $textNode = $element->ownerDocument->createTextNode($text);
+ $element->parentNode->insertBefore($textNode, $element);
+
+ if ($isBlockElement) {
+ for ($i = 0; $i < 2; $i++) {
+ $br = $element->ownerDocument->createElement('br');
+ $element->parentNode->insertBefore($br, $element);
+ }
+ }
+
+ $element->parentNode->removeChild($element);
+ }
+
+ /**
+ * Removes an element but preserves child nodes by moving them into
+ * its original position.
+ *
+ * @param \DOMElement $element element to be removed
+ */
+ public function unwrapContent(\DOMElement $element)
+ {
+ while ($element->hasChildNodes()) {
+ $element->parentNode->insertBefore($element->firstChild, $element);
+ }
+
+ $element->parentNode->removeChild($element);
+ }
+
+ /**
+ * Adds node replacement data.
+ *
+ * @param IHtmlNode $htmlNode node processor instance
+ * @param string $nodeIdentifier replacement node identifier
+ * @param array $data replacement data
+ */
+ public function addNodeData(IHtmlNode $htmlNode, $nodeIdentifier, array $data)
+ {
+ $this->nodeData[] = [
+ 'data' => $data,
+ 'identifier' => $nodeIdentifier,
+ 'object' => $htmlNode,
+ ];
+ }
+
+ /**
+ * Parses an attribute string.
+ *
+ * @param string $attributes base64 and JSON encoded attributes
+ * @return array parsed attributes
+ */
+ public function parseAttributes($attributes)
+ {
+ if (empty($attributes)) {
+ return [];
+ }
+
+ $parsedAttributes = \base64_decode($attributes, true);
+ if ($parsedAttributes !== false) {
+ try {
+ $parsedAttributes = JSON::decode($parsedAttributes);
+ } catch (SystemException $e) {
+ /* parse errors can occur if user provided malicious content - ignore them */
+ $parsedAttributes = [];
+ }
+ }
+
+ return $parsedAttributes;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getHtmlProcessor()
+ {
+ return $this->htmlProcessor;
+ }
+
+ /**
+ * Invokes a html node processor.
+ *
+ * @param IHtmlNode $htmlNode html node processor
+ */
+ protected function invokeHtmlNode(IHtmlNode $htmlNode)
+ {
+ if (!($htmlNode instanceof $this->nodeInterface)) {
+ throw new \InvalidArgumentException(
+ "Node '" . \get_class($htmlNode) . "' does not implement the interface '" . $this->nodeInterface . "'."
+ );
+ }
+
+ $tagName = $htmlNode->getTagName();
+ if (empty($tagName)) {
+ throw new \UnexpectedValueException("Missing tag name for " . \get_class($htmlNode));
+ }
+
+ $elements = [];
- foreach ($this->getDocument()->getElementsByTagName($tagName) as $element) {
++ foreach ($this->getXPath()->query("//{$tagName}") as $element) {
+ $elements[] = $element;
+ }
+
+ if (!empty($elements)) {
+ $htmlNode->process($elements, $this);
+ }
+ }
+
+ /**
+ * Invokes possible html node processors based on found element tag names.
+ *
+ * @param string $classNamePattern full namespace pattern for class guessing
+ * @param string[] $skipTags list of tag names that should be ignored
+ * @param callable $callback optional callback
+ */
+ protected function invokeNodeHandlers($classNamePattern, array $skipTags = [], ?callable $callback = null)
+ {
+ $skipTags = \array_merge($skipTags, ['html', 'head', 'title', 'meta', 'body', 'link']);
+
+ $tags = [];
+ /** @var \DOMElement $tag */
- foreach ($this->getDocument()->getElementsByTagName('*') as $tag) {
++ foreach ($this->getXPath()->query("//*") as $tag) {
+ $tagName = $tag->nodeName;
+ if (!isset($tags[$tagName])) {
+ $tags[$tagName] = $tagName;
+ }
+ }
+
+ foreach ($tags as $tagName) {
+ if (\in_array($tagName, $skipTags)) {
+ continue;
+ }
+
+ $tagName = \preg_replace_callback('/-([a-z])/', static function ($matches) {
+ return \ucfirst($matches[1]);
+ }, $tagName);
+ $className = $classNamePattern . \ucfirst($tagName);
+ if (\class_exists($className)) {
+ if ($callback === null) {
+ $this->invokeHtmlNode(new $className());
+ } else {
+ $callback(new $className());
+ }
+ }
+ }
+ }
}
* @since 3.0
* @method HtmlOutputProcessor getHtmlProcessor()
*/
-class HtmlOutputNodeProcessor extends AbstractHtmlNodeProcessor {
- /**
- * @inheritDoc
- */
- protected $nodeInterface = IHtmlOutputNode::class;
-
- /**
- * desired output type
- * @var string
- */
- protected $outputType = 'text/html';
-
- /**
- * enables keyword highlighting
- * @var boolean
- */
- protected $keywordHighlighting = true;
-
- /**
- * @var string[]
- */
- protected $sourceBBCodes = [];
-
- /**
- * list of HTML tags that should have a trailing newline when converted
- * to text/plain content
- * @var string[]
- */
- public static $plainTextNewlineTags = ['br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'tr'];
-
- /**
- * HtmlOutputNodeProcessor constructor.
- */
- public function __construct() {
- $this->sourceBBCodes = HtmlBBCodeParser::getInstance()->getSourceBBCodes();
- }
-
- /**
- * Sets the desired output type.
- *
- * @param string $outputType desired output type
- */
- public function setOutputType($outputType) {
- $this->outputType = $outputType;
- }
-
- /**
- * Returns the current output type.
- *
- * @return string
- */
- public function getOutputType() {
- return $this->outputType;
- }
-
- /**
- * @inheritDoc
- */
- public function process() {
- // fire event action
- EventHandler::getInstance()->fireAction($this, 'beforeProcess');
-
- // highlight keywords
- $this->highlightKeywords();
-
- $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMetacode());
-
- // dynamic node handlers
- $this->invokeNodeHandlers('wcf\system\html\output\node\HtmlOutputNode', ['woltlab-metacode']);
-
- if ($this->getHtmlProcessor()->removeLinks) {
- foreach ($this->getXPath()->query('//a') as $link) {
- DOMUtil::removeNode($link, true);
- }
- }
-
- if ($this->outputType !== 'text/html') {
- // convert `<p>...</p>` into `...<br><br>`
- foreach ($this->getXPath()->query('//p') as $paragraph) {
- $isLastNode = true;
- $sibling = $paragraph;
- while ($sibling = $sibling->nextSibling) {
- if ($sibling->nodeType === XML_ELEMENT_NODE) {
- if ($sibling->nodeName !== 'br') {
- $isLastNode = false;
- break;
- }
- }
- else if ($sibling->nodeType === XML_TEXT_NODE) {
- if (StringUtil::trim($sibling->textContent) !== '') {
- $isLastNode = false;
- break;
- }
- }
- }
-
- if (!$isLastNode) {
- // check if paragraph only contains <br>
- if (StringUtil::trim($paragraph->textContent) === '') {
- // get last element
- $element = $paragraph->firstChild;
- while ($element && $element->nodeType !== XML_ELEMENT_NODE) {
- $element = $element->nextSibling;
- }
-
- if ($paragraph->childNodes->length === 0 || ($element && $element->nodeName === 'br')) {
- DOMUtil::removeNode($paragraph, true);
- continue;
- }
- }
-
- //for ($i = 0; $i < 2; $i++) {
- $br = $this->getDocument()->createElement('br');
- $paragraph->appendChild($br);
- //}
- }
-
- DOMUtil::removeNode($paragraph, true);
- }
-
- if ($this->outputType === 'text/plain') {
- // remove all `\n` first
- $nodes = [];
- /** @var \DOMText $node */
- foreach ($this->getXPath()->query('//text()') as $node) {
- if (strpos($node->textContent, "\n") !== false) {
- $nodes[] = $node;
- }
- }
- foreach ($nodes as $node) {
- $textNode = $this->getDocument()->createTextNode(preg_replace('~\r?\n~', '', $node->textContent));
- $node->parentNode->insertBefore($textNode, $node);
- $node->parentNode->removeChild($node);
- }
-
- // insert a trailing newline for certain elements, such as `<br>` or `<li>`
- foreach (self::$plainTextNewlineTags as $tagName) {
- foreach ($this->getXPath()->query("//{$tagName}") as $element) {
- $newline = $this->getDocument()->createTextNode("\n");
- $element->parentNode->insertBefore($newline, $element->nextSibling);
- DOMUtil::removeNode($element, true);
- }
- }
-
- // remove all other elements
- foreach ($this->getXPath()->query('//*') as $element) {
- DOMUtil::removeNode($element, true);
- }
- }
- }
- }
-
- /**
- * @inheritDoc
- */
- public function getHtml() {
- $toc = '';
- if (MESSAGE_ENABLE_TOC && $this->getHtmlProcessor()->enableToc && $this->outputType === 'text/html') {
- $context = $this->getHtmlProcessor()->getContext();
- $idPrefix = substr(sha1($context['objectType'] . '-' . $context['objectID']), 0, 8);
-
- $toc = HtmlToc::forMessage($this->getDocument(), $idPrefix);
- }
-
- $html = $toc . parent::getHtml();
-
- if ($this->outputType === 'text/plain') {
- $html = StringUtil::trim($html);
- $html = StringUtil::decodeHTML($html);
- }
-
- return $html;
- }
-
- /**
- * Enables the keyword highlighting.
- *
- * @param boolean $enable
- */
- public function enableKeywordHighlighting($enable = true) {
- $this->keywordHighlighting = $enable;
- }
-
- /**
- * Executes the keyword highlighting.
- */
- protected function highlightKeywords() {
- if (!$this->keywordHighlighting) return;
- if (!count(KeywordHighlighter::getInstance()->getKeywords())) return;
- $keywordPattern = '('.implode('|', KeywordHighlighter::getInstance()->getKeywords()).')';
-
- $nodes = [];
- foreach ($this->getXPath()->query('//text()') as $node) {
- $value = StringUtil::trim($node->textContent);
- if (empty($value)) {
- // skip empty nodes
- continue;
- }
-
- // check if node is within a code element or link
- if ($this->hasCodeParent($node)) {
- continue;
- }
-
- $nodes[] = $node;
- }
- foreach ($nodes as $node) {
- $split = preg_split('+'.$keywordPattern.'+i', $node->textContent, -1, PREG_SPLIT_DELIM_CAPTURE);
- $count = count($split);
- if ($count == 1) continue;
-
- for ($i = 0; $i < $count; $i++) {
- // text
- if ($i % 2 == 0) {
- $node->parentNode->insertBefore($node->ownerDocument->createTextNode($split[$i]), $node);
- }
- // match
- else {
- /** @var \DOMElement $element */
- $element = $node->ownerDocument->createElement('span');
- $element->setAttribute('class', 'highlight');
- $element->appendChild($node->ownerDocument->createTextNode($split[$i]));
- $node->parentNode->insertBefore($element, $node);
- }
- }
-
- DOMUtil::removeNode($node);
- }
- }
-
- /**
- * Returns true if text node is inside a code element, suppressing any
- * auto-detection of content.
- *
- * @param \DOMText $text text node
- * @return boolean true if text node is inside a code element
- */
- protected function hasCodeParent(\DOMText $text) {
- $parent = $text;
- /** @var \DOMElement $parent */
- while ($parent = $parent->parentNode) {
- $nodeName = $parent->nodeName;
- if ($nodeName === 'code' || $nodeName === 'kbd' || $nodeName === 'pre') {
- return true;
- }
- else if ($nodeName === 'woltlab-metacode' && in_array($parent->getAttribute('data-name'), $this->sourceBBCodes)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * @inheritDoc
- */
- protected function invokeHtmlNode(IHtmlNode $htmlNode) {
- /** @var IHtmlOutputNode $htmlNode */
- $htmlNode->setOutputType($this->outputType);
- $htmlNode->setRemoveLinks($this->getHtmlProcessor()->removeLinks);
-
- parent::invokeHtmlNode($htmlNode);
- }
+class HtmlOutputNodeProcessor extends AbstractHtmlNodeProcessor
+{
+ /**
+ * @inheritDoc
+ */
+ protected $nodeInterface = IHtmlOutputNode::class;
+
+ /**
+ * desired output type
+ * @var string
+ */
+ protected $outputType = 'text/html';
+
+ /**
+ * enables keyword highlighting
+ * @var bool
+ */
+ protected $keywordHighlighting = true;
+
+ /**
+ * @var string[]
+ */
+ protected $sourceBBCodes = [];
+
+ /**
+ * list of HTML tags that should have a trailing newline when converted
+ * to text/plain content
+ * @var string[]
+ */
+ public static $plainTextNewlineTags = ['br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'tr'];
+
+ /**
+ * HtmlOutputNodeProcessor constructor.
+ */
+ public function __construct()
+ {
+ $this->sourceBBCodes = HtmlBBCodeParser::getInstance()->getSourceBBCodes();
+ }
+
+ /**
+ * Sets the desired output type.
+ *
+ * @param string $outputType desired output type
+ */
+ public function setOutputType($outputType)
+ {
+ $this->outputType = $outputType;
+ }
+
+ /**
+ * Returns the current output type.
+ *
+ * @return string
+ */
+ public function getOutputType()
+ {
+ return $this->outputType;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function process()
+ {
+ // fire event action
+ EventHandler::getInstance()->fireAction($this, 'beforeProcess');
+
+ // highlight keywords
+ $this->highlightKeywords();
+
+ $this->invokeHtmlNode(new HtmlOutputNodeWoltlabMetacode());
+
+ $this->invokeHtmlNode(new HtmlOutputUnfurlUrlNode());
+
+ // dynamic node handlers
+ $this->invokeNodeHandlers('wcf\system\html\output\node\HtmlOutputNode', ['woltlab-metacode']);
+
+ if ($this->getHtmlProcessor()->removeLinks) {
- $links = $this->getDocument()->getElementsByTagName('a');
- while ($links->length) {
- DOMUtil::removeNode($links->item(0), true);
++ foreach ($this->getXPath()->query('//a') as $link) {
++ DOMUtil::removeNode($link, true);
+ }
+ }
+
+ if ($this->outputType !== 'text/html') {
+ // convert `<p>...</p>` into `...<br><br>`
- $paragraphs = $this->getDocument()->getElementsByTagName('p');
- while ($paragraphs->length) {
- $paragraph = $paragraphs->item(0);
-
++ foreach ($this->getXPath()->query('//p') as $paragraph) {
+ $isLastNode = true;
+ $sibling = $paragraph;
+ while ($sibling = $sibling->nextSibling) {
+ if ($sibling->nodeType === \XML_ELEMENT_NODE) {
+ if ($sibling->nodeName !== 'br') {
+ $isLastNode = false;
+ break;
+ }
+ } elseif ($sibling->nodeType === \XML_TEXT_NODE) {
+ if (StringUtil::trim($sibling->textContent) !== '') {
+ $isLastNode = false;
+ break;
+ }
+ }
+ }
+
+ if (!$isLastNode) {
+ // check if paragraph only contains <br>
+ if (StringUtil::trim($paragraph->textContent) === '') {
+ // get last element
+ $element = $paragraph->firstChild;
+ while ($element && $element->nodeType !== \XML_ELEMENT_NODE) {
+ $element = $element->nextSibling;
+ }
+
+ if ($paragraph->childNodes->length === 0 || ($element && $element->nodeName === 'br')) {
+ DOMUtil::removeNode($paragraph, true);
+ continue;
+ }
+ }
+
+ //for ($i = 0; $i < 2; $i++) {
+ $br = $this->getDocument()->createElement('br');
+ $paragraph->appendChild($br);
+ //}
+ }
+
+ DOMUtil::removeNode($paragraph, true);
+ }
+
+ if ($this->outputType === 'text/plain') {
+ // remove all `\n` first
+ $nodes = [];
+ /** @var \DOMText $node */
+ foreach ($this->getXPath()->query('//text()') as $node) {
+ if (\strpos($node->textContent, "\n") !== false) {
+ $nodes[] = $node;
+ }
+ }
+ foreach ($nodes as $node) {
+ $textNode = $this->getDocument()->createTextNode(\preg_replace('~\r?\n~', '', $node->textContent));
+ $node->parentNode->insertBefore($textNode, $node);
+ $node->parentNode->removeChild($node);
+ }
+
+ // insert a trailing newline for certain elements, such as `<br>` or `<li>`
+ foreach (self::$plainTextNewlineTags as $tagName) {
- $elements = $this->getDocument()->getElementsByTagName($tagName);
- while ($elements->length) {
- $element = $elements->item(0);
-
++ foreach ($this->getXPath()->query("//{$tagName}") as $element) {
+ $newline = $this->getDocument()->createTextNode("\n");
+ $element->parentNode->insertBefore($newline, $element->nextSibling);
+ DOMUtil::removeNode($element, true);
+ }
+ }
+
+ // remove all other elements
- $elements = $this->getDocument()->getElementsByTagName('*');
- while ($elements->length) {
- DOMUtil::removeNode($elements->item(0), true);
++ foreach ($this->getXPath()->query('//*') as $element) {
++ DOMUtil::removeNode($element, true);
+ }
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getHtml()
+ {
+ $toc = '';
+ if (MESSAGE_ENABLE_TOC && $this->getHtmlProcessor()->enableToc && $this->outputType === 'text/html') {
+ $context = $this->getHtmlProcessor()->getContext();
+ $idPrefix = \substr(\sha1($context['objectType'] . '-' . $context['objectID']), 0, 8);
+
+ $toc = HtmlToc::forMessage($this->getDocument(), $idPrefix);
+ }
+
+ $html = $toc . parent::getHtml();
+
+ if ($this->outputType === 'text/plain') {
+ $html = StringUtil::trim($html);
+ $html = StringUtil::decodeHTML($html);
+ }
+
+ return $html;
+ }
+
+ /**
+ * Enables the keyword highlighting.
+ *
+ * @param bool $enable
+ */
+ public function enableKeywordHighlighting($enable = true)
+ {
+ $this->keywordHighlighting = $enable;
+ }
+
+ /**
+ * Executes the keyword highlighting.
+ */
+ protected function highlightKeywords()
+ {
+ if (!$this->keywordHighlighting) {
+ return;
+ }
+ if (!\count(KeywordHighlighter::getInstance()->getKeywords())) {
+ return;
+ }
+ $keywordPattern = '(' . \implode('|', KeywordHighlighter::getInstance()->getKeywords()) . ')';
+
+ $nodes = [];
+ foreach ($this->getXPath()->query('//text()') as $node) {
+ $value = StringUtil::trim($node->textContent);
+ if (empty($value)) {
+ // skip empty nodes
+ continue;
+ }
+
+ // check if node is within a code element or link
+ if ($this->hasCodeParent($node)) {
+ continue;
+ }
+
+ $nodes[] = $node;
+ }
+ foreach ($nodes as $node) {
+ $split = \preg_split('+' . $keywordPattern . '+i', $node->textContent, -1, \PREG_SPLIT_DELIM_CAPTURE);
+ $count = \count($split);
+ if ($count == 1) {
+ continue;
+ }
+
+ for ($i = 0; $i < $count; $i++) {
+ // text
+ if ($i % 2 == 0) {
+ $node->parentNode->insertBefore($node->ownerDocument->createTextNode($split[$i]), $node);
+ } // match
+ else {
+ /** @var \DOMElement $element */
+ $element = $node->ownerDocument->createElement('span');
+ $element->setAttribute('class', 'highlight');
+ $element->appendChild($node->ownerDocument->createTextNode($split[$i]));
+ $node->parentNode->insertBefore($element, $node);
+ }
+ }
+
+ DOMUtil::removeNode($node);
+ }
+ }
+
+ /**
+ * Returns true if text node is inside a code element, suppressing any
+ * auto-detection of content.
+ *
+ * @param \DOMText $text text node
+ * @return bool true if text node is inside a code element
+ */
+ protected function hasCodeParent(\DOMText $text)
+ {
+ $parent = $text;
+ /** @var \DOMElement $parent */
+ while ($parent = $parent->parentNode) {
+ $nodeName = $parent->nodeName;
+ if ($nodeName === 'code' || $nodeName === 'kbd' || $nodeName === 'pre') {
+ return true;
+ } elseif (
+ $nodeName === 'woltlab-metacode'
+ && \in_array($parent->getAttribute('data-name'), $this->sourceBBCodes)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function invokeHtmlNode(IHtmlNode $htmlNode)
+ {
+ /** @var IHtmlOutputNode $htmlNode */
+ $htmlNode->setOutputType($this->outputType);
+ $htmlNode->setRemoveLinks($this->getHtmlProcessor()->removeLinks);
+
+ parent::invokeHtmlNode($htmlNode);
+ }
}
/**
* Provides helper methods to work with PHP's DOM implementation.
- *
- * @author Alexander Ebert
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @package WoltLabSuite\Core\Util
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2019 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package WoltLabSuite\Core\Util
*/
-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 a static list of child nodes of provided element.
- *
- * @param \DOMElement $element target element
- * @return \DOMNode[] list of child nodes
- */
- public static function getChildNodes(\DOMElement $element) {
- $nodes = [];
- foreach ($element->childNodes as $node) {
- $nodes[] = $node;
- }
-
- return $nodes;
- }
-
- /**
- * Returns the common ancestor of both nodes.
- *
- * @param \DOMNode $node1 first node
- * @param \DOMNode $node2 second node
- * @return \DOMNode|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 a non-live collection of elements.
- *
- * @param (\DOMDocument|\DOMElement) $context context element
- * @param string $tagName tag name
- * @return \DOMElement[] list of elements
- * @throws SystemException
- */
- public static function getElements($context, $tagName) {
- if (!($context instanceof \DOMDocument) && !($context instanceof \DOMElement)) {
- throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
- }
-
- $elements = [];
- foreach ($context->getElementsByTagName($tagName) as $element) {
- $elements[] = $element;
- }
-
- return $elements;
- }
-
- /**
- * Returns the immediate parent element before provided ancestor 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;
- }
-
- /**
- * Returns a cloned parent tree that is virtually readonly. In fact it can be
- * modified, but all changes are non permanent and do not affect the source
- * document at all.
- *
- * @param \DOMNode $node node
- * @return \DOMElement[] list of parent elements
- */
- public static function getReadonlyParentTree(\DOMNode $node) {
- $tree = [];
- /** @var \DOMElement $parent */
- foreach (self::getParents($node) as $parent) {
- // do not include <body>, <html> and the document itself
- if ($parent->nodeName === 'body') break;
-
- $tree[] = $parent->cloneNode(false);
- }
-
- return $tree;
- }
-
- /**
- * 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;
-
- $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 \RuntimeException("Unable to determine relative node position.");
- }
-
- /**
- * Returns true if there is at least one parent with the provided tag name.
- *
- * @param \DOMElement $element start element
- * @param string $tagName tag name to match
- * @return boolean
- */
- public static function hasParent(\DOMElement $element, $tagName) {
- while ($element = $element->parentNode) {
- if ($element->nodeName === $tagName) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * 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 this node is empty.
- *
- * @param \DOMNode $node node
- * @return boolean true if node is empty
- */
- public static function isEmpty(\DOMNode $node) {
- if ($node->nodeType === XML_TEXT_NODE) {
- return (StringUtil::trim($node->nodeValue) === '');
- }
- else if ($node->nodeType === XML_ELEMENT_NODE) {
- /** @var \DOMElement $node */
- if (self::isVoidElement($node)) {
- return false;
- }
- else if ($node->hasChildNodes()) {
- for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
- if (!self::isEmpty($node->childNodes->item($i))) {
- return false;
- }
- }
- }
-
- return true;
- }
-
- return true;
- }
-
- /**
- * 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->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;
- }
-
- /**
- * Nodes can get partially destroyed in which they're still an
- * actual DOM node (such as \DOMElement) but almost their entire
- * body is gone, including the `nodeType` attribute.
- *
- * @param \DOMNode $node node
- * @return boolean true if node has been destroyed
- */
- public static function isRemoved(\DOMNode $node) {
- return !isset($node->nodeType);
- }
-
- /**
- * Returns true if provided element is a void element. Void elements are elements
- * that neither contain content nor have a closing tag, such as `<br>`.
- *
- * @param \DOMElement $element element
- * @return boolean true if provided element is a void element
- */
- public static function isVoidElement(\DOMElement $element) {
- if (preg_match('~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~', $element->nodeName)) {
- 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);
- }
-
- /**
- * Normalizes an element by joining adjacent text nodes.
- *
- * @param \DOMElement $element target element
- */
- public static function normalize(\DOMElement $element) {
- $childNodes = self::getChildNodes($element);
- /** @var \DOMNode $lastTextNode */
- $lastTextNode = null;
- foreach ($childNodes as $childNode) {
- if ($childNode->nodeType !== XML_TEXT_NODE) {
- $lastTextNode = null;
- continue;
- }
-
- if ($lastTextNode === null) {
- $lastTextNode = $childNode;
- }
- else {
- // merge with last text node
- $newTextNode = $childNode->ownerDocument->createTextNode($lastTextNode->textContent . $childNode->textContent);
- $element->insertBefore($newTextNode, $lastTextNode);
-
- $element->removeChild($lastTextNode);
- $element->removeChild($childNode);
-
- $lastTextNode = $newTextNode;
- }
- }
- }
-
- /**
- * 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) {
- $parent = $node->parentNode ?: $node->ownerDocument;
-
- if ($preserveChildNodes) {
- if (!($node instanceof \DOMElement)) {
- throw new \InvalidArgumentException("Preserving child nodes is only supported for DOMElement.");
- }
-
- $children = [];
- foreach ($node->childNodes as $childNode) {
- $children[] = $childNode;
- }
-
- foreach ($children as $child) {
- $parent->insertBefore($child, $node);
- }
- }
-
- $parent->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 \DOMNode 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 ($cloneNode === null) {
- // target node is already a direct descendant of the ancestor
- // node, no need to split anything
- return $node;
- }
- else 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) {
- /** @var \DOMElement $newNode */
- $newNode = $parent->cloneNode();
- self::insertBefore($newNode, $parent);
-
- while ($currentNode->previousSibling) {
- self::prepend($currentNode->previousSibling, $newNode);
- }
-
- $currentNode = $parent;
- }
- }
- else {
- if ($cloneNode === null) {
- // target node is already a direct descendant of the ancestor
- // node, no need to split anything
- return $node;
- }
- 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);
- }
-
- /**
- * Forbid creation of DOMUtil objects.
- */
- private function __construct() {
- // does nothing
- }
+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 bool 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 a static list of child nodes of provided element.
+ *
+ * @param \DOMElement $element target element
+ * @return \DOMNode[] list of child nodes
+ */
+ public static function getChildNodes(\DOMElement $element)
+ {
+ $nodes = [];
+ foreach ($element->childNodes as $node) {
+ $nodes[] = $node;
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * Returns the common ancestor of both nodes.
+ *
+ * @param \DOMNode $node1 first node
+ * @param \DOMNode $node2 second node
+ * @return \DOMNode|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;
+ }
+ }
+ }
+
+ /**
+ * Returns a non-live collection of elements.
+ *
+ * @param (\DOMDocument|\DOMElement) $context context element
+ * @param string $tagName tag name
+ * @return \DOMElement[] list of elements
+ * @throws SystemException
+ */
+ public static function getElements($context, $tagName)
+ {
+ if (!($context instanceof \DOMDocument) && !($context instanceof \DOMElement)) {
+ throw new SystemException("Expected context to be either of type \\DOMDocument or \\DOMElement.");
+ }
+
+ $elements = [];
+ foreach ($context->getElementsByTagName($tagName) as $element) {
+ $elements[] = $element;
+ }
+
+ return $elements;
+ }
+
+ /**
+ * Returns the immediate parent element before provided ancestor 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;
+ }
+
+ $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 bool $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;
+ }
+
+ /**
+ * Returns a cloned parent tree that is virtually readonly. In fact it can be
+ * modified, but all changes are non permanent and do not affect the source
+ * document at all.
+ *
+ * @param \DOMNode $node node
+ * @return \DOMElement[] list of parent elements
+ */
+ public static function getReadonlyParentTree(\DOMNode $node)
+ {
+ $tree = [];
+ /** @var \DOMElement $parent */
+ foreach (self::getParents($node) as $parent) {
+ // do not include <body>, <html> and the document itself
+ if ($parent->nodeName === 'body') {
+ break;
+ }
+
+ $tree[] = $parent->cloneNode(false);
+ }
+
+ return $tree;
+ }
+
+ /**
+ * 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;
+
+ $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 \RuntimeException("Unable to determine relative node position.");
+ }
+
+ /**
+ * Returns true if there is at least one parent with the provided tag name.
+ *
+ * @param \DOMElement $element start element
+ * @param string $tagName tag name to match
+ * @return bool
+ */
+ public static function hasParent(\DOMElement $element, $tagName)
+ {
+ while ($element = $element->parentNode) {
+ if ($element->nodeName === $tagName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 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 this node is empty.
+ *
+ * @param \DOMNode $node node
+ * @return bool true if node is empty
+ */
+ public static function isEmpty(\DOMNode $node)
+ {
+ if ($node->nodeType === \XML_TEXT_NODE) {
+ return StringUtil::trim($node->nodeValue) === '';
+ } elseif ($node->nodeType === \XML_ELEMENT_NODE) {
+ /** @var \DOMElement $node */
+ if (self::isVoidElement($node)) {
+ return false;
+ } elseif ($node->hasChildNodes()) {
+ for ($i = 0, $length = $node->childNodes->length; $i < $length; $i++) {
+ if (!self::isEmpty($node->childNodes->item($i))) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if given node is the first node of its given ancestor.
+ *
+ * @param \DOMNode $node node
+ * @param \DOMElement $ancestor ancestor element
+ * @return bool 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->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+ return true;
+ } else {
+ return self::isFirstNode($node->parentNode, $ancestor);
+ }
+ } elseif ($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 bool 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.");
+ } elseif ($node->parentNode === $ancestor || $node->parentNode->nodeName === 'body') {
+ return true;
+ } else {
+ return self::isLastNode($node->parentNode, $ancestor);
+ }
+ } elseif ($node->parentNode->nodeName === 'body') {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Nodes can get partially destroyed in which they're still an
+ * actual DOM node (such as \DOMElement) but almost their entire
+ * body is gone, including the `nodeType` attribute.
+ *
+ * @param \DOMNode $node node
+ * @return bool true if node has been destroyed
+ */
+ public static function isRemoved(\DOMNode $node)
+ {
+ return !isset($node->nodeType);
+ }
+
+ /**
+ * Returns true if provided element is a void element. Void elements are elements
+ * that neither contain content nor have a closing tag, such as `<br>`.
+ *
+ * @param \DOMElement $element element
+ * @return bool true if provided element is a void element
+ */
+ public static function isVoidElement(\DOMElement $element)
+ {
+ if (
+ \preg_match(
+ '~^(area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)$~',
+ $element->nodeName
+ )
+ ) {
+ 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."
+ );
+ } elseif ($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);
+ }
+
+ /**
+ * Normalizes an element by joining adjacent text nodes.
+ *
+ * @param \DOMElement $element target element
+ */
+ public static function normalize(\DOMElement $element)
+ {
+ $childNodes = self::getChildNodes($element);
+ /** @var \DOMNode $lastTextNode */
+ $lastTextNode = null;
+ foreach ($childNodes as $childNode) {
+ if ($childNode->nodeType !== \XML_TEXT_NODE) {
+ $lastTextNode = null;
+ continue;
+ }
+
+ if ($lastTextNode === null) {
+ $lastTextNode = $childNode;
+ } else {
+ // merge with last text node
+ $newTextNode = $childNode
+ ->ownerDocument
+ ->createTextNode($lastTextNode->textContent . $childNode->textContent);
+ $element->insertBefore($newTextNode, $lastTextNode);
+
+ $element->removeChild($lastTextNode);
+ $element->removeChild($childNode);
+
+ $lastTextNode = $newTextNode;
+ }
+ }
+ }
+
+ /**
+ * 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 bool $preserveChildNodes preserve child nodes, only supported for elements
+ */
+ public static function removeNode(\DOMNode $node, $preserveChildNodes = false)
+ {
++ $parent = $node->parentNode ?: $node->ownerDocument;
++
+ 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);
++ $children = [];
++ foreach ($node->childNodes as $childNode) {
++ $children[] = $childNode;
++ }
++
++ foreach ($children as $child) {
++ $parent->insertBefore($child, $node);
+ }
+ }
+
- self::getParentNode($node)->removeChild($node);
++ $parent->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 bool $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 bool $splitBefore true if nodes before `$node` should be moved into a new node, false to split nodes after `$node`
+ * @return \DOMNode 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 ($cloneNode === null) {
+ // target node is already a direct descendant of the ancestor
+ // node, no need to split anything
+ return $node;
+ } elseif (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) {
+ /** @var \DOMElement $newNode */
+ $newNode = $parent->cloneNode();
+ self::insertBefore($newNode, $parent);
+
+ while ($currentNode->previousSibling) {
+ self::prepend($currentNode->previousSibling, $newNode);
+ }
+
+ $currentNode = $parent;
+ }
+ } else {
+ if ($cloneNode === null) {
+ // target node is already a direct descendant of the ancestor
+ // node, no need to split anything
+ return $node;
+ } elseif (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);
+ }
+
+ /**
+ * Forbid creation of DOMUtil objects.
+ */
+ private function __construct()
+ {
+ // does nothing
+ }
}