* @package zin * @version $Id * @link https://www.zentao.net */ namespace zin; require_once __DIR__ . DS . 'helper.func.php'; require_once __DIR__ . DS . 'selector.func.php'; require_once __DIR__ . DS . 'props.class.php'; /** * The base node class. */ class node implements \JsonSerializable { /** * Define properties * * @access public * @var array */ protected static array $defineProps = array(); /** * Default properties * * @access public * @var array */ protected static array $defaultProps = array(); protected static array $defineBlocks = array(); public string $gid; public ?node $parent = null; public ?node $rootNode = null; public props $props; public array $blocks = array(); public bool $removed = false; public ?array $replacedWith = null; public ?stdClass $buildData = null; public array $eventBindings = array(); public ?bool $notRenderInGlobal = null; public function __construct(mixed ...$args) { $this->gid = static::nextGid(); $this->props = new props(); disableGlobalRender(); $this->setDefaultProps(static::getDefaultProps()); $this->add($args); $this->created(); enableGlobalRender(); renderInGlobal($this); } public function __debugInfo(): array { return (array)$this->toJSON(); } public function __toString(): string { return $this->render(); } public function fullType(): string { return get_called_class(); } public function type(): string { $type = $this->fullType(); if(str_contains($type, '\\')) { $type = substr($type, strrpos($type, '\\') + 1); } return $type; } public function id(): ?string { return strval($this->props->get('id')); } public function displayID(): string { $displayID = $this->fullType() . '~' . $this->gid; $id = $this->id(); if(!empty($id)) $displayID .= "#$id"; return $displayID; } /** * Check if the element is match any of the selectors * @param string|array|object $selectors */ public function is(string|array|object $selectors): bool { $list = parseSelectors($selectors); foreach($list as $selector) { if($this->isMatch($selector)) return true; } return false; } public function isMatch(object $selector): bool { if(!empty($selector->id) && $this->id() !== $selector->id) return false; if(!empty($selector->tag) && $this->type() !== $selector->tag) return false; if(!empty($selector->class) && !$this->props->class->has($selector->class)) return false; if(!empty($selector->parents) && !$this->hasParents(...$selector->parents)) return false; return true; } public function hasParents(object|string ...$parentSelectors): bool { $parent = $this->parent; if(!$parent) return false; foreach($parentSelectors as $selector) { $parent = $parent->closest($selector); if(!$parent) return false; } return true; } public function off(string $event) { unset($this->eventBindings[$event]); $this->props->remove("@$event"); } public function closest(string|array|object $selectors): ?node { $list = parseSelectors($selectors, true); $node = $this; while($node) { if($node->is($list)) return $node; $node = $node->parent; } return null; } public function find(string|array|object $selectors, bool $first = false, bool $reverse = false, bool $prebuild = true): array { if($prebuild) $this->prebuild(); return findInNode(parseSelectors($selectors, true), $this, $first, $reverse); } public function findFirst(string|array|object $selectors): ?node { $results = $this->find($selectors, true); return empty($results) ? null : reset($results); } public function findLast(string|array|object $selectors): ?node { $results = $this->find($selectors, true, true); return empty($results) ? null : end($results); } public function prop(array|string $name, mixed $defaultValue = null): mixed { if(is_array($name)) { $values = array(); foreach($name as $index => $propName) { $values[] = $this->onGetProp($propName, is_array($defaultValue) ? (isset($defaultValue[$propName]) ? $defaultValue[$propName] : $defaultValue[$index]) : $defaultValue); } return $values; } return $this->onGetProp($name, $defaultValue); } /** * Set property, an array can be passed to set multiple properties * * @access public * @param props|array|string $prop - Property name or properties list * @param mixed $value - Property value */ public function setProp(props|array|string $prop, mixed $value = null) { if($prop instanceof props) $prop = $prop->toJSON(); if(is_array($prop)) { foreach($prop as $name => $value) $this->setProp($name, $value); return $this; } if(!is_string($prop) || empty($prop)) return $this; if($prop[0] === '#') { $this->add($value, substr($prop, 1)); return $this; } $this->onSetProp($prop, $value); return $this; } public function hasProp(): bool { $names = func_get_args(); if(empty($names)) return false; foreach($names as $name) { if(!$this->props->has($name)) return false; } return true; } public function setDefaultProps(string|array $props, mixed $value = null) { if(is_string($props)) $props = array($props => $value); if(!is_array($props) || empty($props)) return; foreach($props as $name => $value) { if($this->props->isset($name)) continue; $this->setProp($name, $value); } } public function getRestProps(): array { return $this->props->skip(array_keys(static::definedPropsList())); } public function getDefinedProps(): array { return $this->props->pick(array_keys(static::definedPropsList())); } public function add(mixed $item, string $blockName = 'children', bool $prepend = false) { if($item === null || is_bool($item)) return; if($item instanceof \Closure) $item = $item(); if(is_array($item)) { foreach($item as $child) $this->add($child, $blockName, $prepend); return; } if(isDirective($item)) $this->directive($item, $blockName); else $this->addToBlock($blockName, $item, $prepend); } public function addToBlock(string $name, mixed $child, bool $prepend = false) { if($child === null || is_bool($child)) return; if(is_array($child)) { foreach($child as $blockChild) { $this->addToBlock($name, $blockChild, $prepend); } return; } if($child instanceof node) $child->parent = $this; if($child instanceof node) { if(isset($child->parent)) skipRenderInGlobal($child); $child->parent = $this; } if($name === 'children' && $child instanceof node) { $blockName = static::getNameFromBlockMap($child->fullType()); if($blockName !== null) $name = $blockName; } elseif(is_string($child)) { /* Encode html special chars. */ $child = htmlspecialchars(strval($child), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, null, false); } $result = $name === 'children' ? $this->onAddChild($child) : $this->onAddBlock($child, $name); if($result === false) return; if($result !== null && $result !== true) $child = $result; if(isset($this->blocks[$name])) { if($prepend) array_unshift($this->blocks[$name], $child); else $this->blocks[$name][] = $child; } else { $this->blocks[$name] = array($child); } } public function directive(iDirective $directive, string $blockName = 'children') { if(!isset($directive->parent) || !$directive->parent) $directive->parent = $this; $directive->apply($this, $blockName); } public function addChild(mixed $child) { return $this->addToBlock('children', $child); } public function remove() { $this->removed = true; } public function empty(?string $blockName = null) { if($blockName) unset($this->blocks[$blockName]); else $this->blocks = array(); $this->removeBuildData('all'); } public function bindEvent(string $event, object|array $info) { if($info instanceof jsCallback) { $this->props->bindEvent($event, $info->toJS()); $this->removeBuildData(); return; } if(is_array($info)) $info = (object)$info; if(!isset($this->eventBindings[$event])) $this->eventBindings[$event] = array(); $this->eventBindings[$event][] = $info; if(!$this->hasProp('id')) $this->setProp('id', $this->gid); } public function buildEvents(): ?string { $events = $this->eventBindings; if(empty($events)) return null; $id = $this->id(); $code = array ( '$(function(){', $this->type() === 'html' ? 'const ele = document;' : 'const ele = document.getElementById("' . (empty($id) ? $this->gid : $id) . '");if(!ele)return;const $ele = $(ele); const events = new Set(($ele.attr("data-zin-events") || "").split(" ").filter(Boolean));' ); foreach($events as $event => $bindingList) { $code[] = "\$ele.on('$event.zin.on', function(e){"; foreach($bindingList as $binding) { if(is_string($binding)) $binding = (object)array('handler' => $binding); $selector = isset($binding->selector) ? $binding->selector : null; $handler = isset($binding->handler) ? trim($binding->handler) : ''; $stop = isset($binding->stop) ? $binding->stop : null; $prevent = isset($binding->prevent) ? $binding->prevent : null; $self = isset($binding->self) ? $binding->self : null; $code[] = '(function(){'; if($selector) $code[] = "const target = e.target.closest('$selector');if(!target) return;"; else $code[] = "const target = ele;"; if($self) $code[] = "if(ele !== e.target) return;"; if($stop) $code[] = "e.stopPropagation();"; if($prevent) $code[] = "e.preventDefault();"; if(preg_match('/^[$A-Z_][0-9A-Z_$\[\]."\']*$/i', $handler)) $code[] = "($handler).call(target,e);"; else $code[] = $handler; $code[] = '})();'; } $code[] = "});events.add('$event');"; } $code[] = '$ele.attr("data-zin-events", Array.from(events).join(" "));'; $code[] = '});'; return implode("\n", $code); } public function render(): string { if($this->removed) return ''; $data = new stdClass(); $data->html = renderToHtml(...$this->buildAll()); context()->handleRenderNode($data, $this); return $data->html; } public function renderInner(): string { if($this->removed) return ''; return renderToHtml(...$this->children()); } public function removeBuildData(string $type = 'children') { if(!$this->buildData) return; if($type === 'all') { $this->buildData = new stdClass(); } elseif($type === 'before') { unset($this->buildData->before); } elseif($type === 'after') { unset($this->buildData->after); } else { unset($this->buildData->content); unset($this->buildData->children); } } public function prebuild(bool $force = false): stdClass { $firstBuild = ($this->buildData === null || $force); if($firstBuild) { $context = context(); $context->handleBeforeBuildNode($this); $data = new stdClass(); $data->before = prebuild($this->buildBefore(), $this); $data->children = prebuild($this->children(), $this); $data->content = prebuild($this->buildContents(), $this); $data->after = prebuild($this->buildAfter(), $this); $this->buildData = $data; $context->handleBuildNode($data, $this); } else { if(!isset($this->buildData->before)) $this->buildData->before = prebuild($this->buildBefore(), $this); if(!isset($this->buildData->children)) $this->buildData->children = prebuild($this->children(), $this); if(!isset($this->buildData->content)) $this->buildData->content = prebuild($this->buildContents(), $this); if(!isset($this->buildData->after)) $this->buildData->after = prebuild($this->buildAfter(), $this); } return $this->buildData; } public function buildContents(): array { $content = $this->build(); if(is_null($content) || is_bool($content)) $content = array(); elseif(!is_array($content)) $content = array($content); return $content; } public function buildAll(): array { if($this->replacedWith !== null) return $this->replacedWith; $data = $this->prebuild(); return array_merge($data->before, $data->content, $data->after); } public function replaceWith(mixed ...$args) { $this->replacedWith = $args; } public function children(): array { return $this->block('children'); } public function block(string $name): array { $list = array(); if(isset($this->blocks[$name])) { $items = $this->blocks[$name]; foreach($items as $item) { if(is_array($item)) $list = array_merge($list, $item); else $list[] = $item; } } return $list; } public function hasBlock(string $name): bool { return isset($this->blocks[$name]); } /** * Convert to JSON object. * * @access public * @return object */ public function toJSON(): object { $json = new stdClass(); $json->gid = $this->gid; $json->type = $this->type(); $json->props = $this->props->toJSON(); $json->blocks = array(); foreach($this->blocks as $key => $block) { foreach($block as $index => $child) { if($child instanceof node || (is_object($child) && method_exists($child, 'toJSON'))) { $block[$index] = $child->toJSON(); } } if($key === 'children') { $json->$key = $block; unset($json->blocks[$key]); } else { $json->blocks[$key] = $block; } } if(!$json->blocks) unset($json->blocks); $id = $this->id(); if($id !== null) $json->id = $id; $parent = $this->parent; if($parent !== null) $json->parent = $parent->displayID(); if($this->removed) $json->removed = true; return $json; } /** * Serialized to JSON string. * * @access public * @return mixed */ #[\ReturnTypeWillChange] public function jsonSerialize(): mixed { return json_encode($this->toJSON()); } /** * Trigger error in debug mode. * * @access public * @param string $message * @param int $level * @return void */ public function triggerError(string $message, int $level = E_USER_ERROR) { triggerError("{$this->displayID()}: $message", $level); } protected function build() { return $this->children(); } protected function buildBefore(): array { return $this->block('before'); } protected function buildAfter(): array { return $this->block('after'); } protected function created() { } protected function onAddChild(mixed $child) { $this->removeBuildData(); return $child; } protected function onAddBlock(mixed $child, string $name) { $this->removeBuildData($name); return $child; } protected function onSetProp(array|string $prop, mixed $value) { if($prop === 'id' && $value === '$GID') $value = $this->gid; $this->props->set($prop, $value); $this->removeBuildData(); } protected function onGetProp(string $prop, mixed $defaultValue): mixed { return $this->props->get($prop, $defaultValue); } /** * Check errors in debug mode. * * @access protected * @return void */ protected function checkErrors() { if(!isDebug()) return; $definedProps = static::definedPropsList(); foreach($definedProps as $name => $definition) { if($this->hasProp($name)) continue; if(isset($definition['default']) && $definition['default'] !== null) continue; if(isset($definition['optional']) && $definition['optional']) continue; $this->triggerError("The value of property \"$name: {$definition['type']}\" is required."); } $wgErrors = $this->onCheckErrors(); if(empty($wgErrors)) return; foreach($wgErrors as $error) { if(is_array($error)) $this->triggerError(...$error); else $this->triggerError($error); } } /** * The lifecycle method for checking errors in debug mode. * * @access protected * @return array|null */ protected function onCheckErrors(): ?array { return null; } protected static array $definedPropsMap = array(); protected static array $blockMap = array(); protected static array $gidMap = array(); protected static ?string $pageKey = null; public static function nextGid($prefix = 'zin_', $type = null): string { global $config; if(!isset($config->clientCache) || !$config->clientCache) { return $prefix . uniqid(); } if($type === null) { $type = get_called_class(); if(str_starts_with($type, 'zin\\')) $type = substr($type, 4); } $lastID = isset(static::$gidMap[$type]) ? static::$gidMap[$type] : -1; $nextID = $lastID + 1; static::$gidMap[$type] = $nextID; $key = static::$pageKey; if($key === null) { global $app; $key = $app->rawModule . '_' . $app->rawMethod . '_'; $pageDataID = data($app->rawModule . 'ID'); if(!$pageDataID) { $pageData = data($app->rawModule); if(is_object($pageData) && isset($pageData->id)) $pageDataID = $pageData->id; elseif(is_array($pageData) && isset($pageData['id'])) $pageDataID = $pageData['id']; } if($pageDataID) $key .= $pageDataID . '_'; static::$pageKey = $key; } $id = $prefix . $key . $type; if($nextID > 0) $id .= "_$nextID"; return $id; } public static function getBlockMap(): array { $type = get_called_class(); if(!isset(node::$blockMap[$type])) { $blockMap = array(); if(is_array(static::$defineBlocks)) { foreach(static::$defineBlocks as $blockName => $setting) { if(!isset($setting['map'])) continue; $map = $setting['map']; if(is_string($map)) $map = explode(',', $map); foreach($map as $name) $blockMap[$name] = $blockName; } } node::$blockMap[$type] = $blockMap; } return node::$blockMap[$type]; } public static function getNameFromBlockMap(string $type): ?string { $blockMap = static::getBlockMap(); if(str_starts_with($type, 'zin\\')) $type = substr($type, 4); return isset($blockMap[$type]) ? $blockMap[$type] : null; } public static function definedPropsList(?string $type = null): array { if($type === null) $type = get_called_class(); if(!isset(node::$definedPropsMap[$type]) && $type === get_called_class()) { node::$definedPropsMap[$type] = static::parsePropsDefinition(); } return node::$definedPropsMap[$type]; } public static function getDefaultProps(?string $type = null): array { $type = $type ? $type : get_called_class(); $defaultProps = array(); $definedPropsList = static::definedPropsList($type); foreach($definedPropsList as $name => $definition) { if(!isset($definition['default'])) continue; $defaultProps[$name] = $definition['default']; } return $defaultProps; } /** * Parse props definition * @param $definition * @example * * $definition = array('name', 'desc:string', 'title?:string|array', 'icon?:string="star"'); * $definition = array('name' => 'mixed', 'desc' => '?string', 'title' => array('type' => 'string|array', 'optional' => true), 'icon' => array('type' => 'string', 'default' => 'star', 'optional' => true)))) */ protected static function parsePropsDefinition(): array { $parentClass = get_parent_class(get_called_class()); $parentProps = array(); $defaultProps = static::$defaultProps; $definition = static::$defineProps; if($parentClass) { if($definition === $parentClass::$defineProps) $definition = array(); if($defaultProps === $parentClass::$defaultProps) $defaultProps = array(); $parentProps = call_user_func("$parentClass::definedPropsList", $parentClass); } return parsePropsMap($definition, $parentProps, $defaultProps); } } function findInNode(array $selectors, node|array $list, bool $first = false, bool $reverse = false, bool $onlyContent = true): array { if($list instanceof node) { $data = $list->buildData; if(!$data) return array(); $list = $data->content ? $data->content : array(); if(!$onlyContent) $list = array_merge($data->before ? $data->before : array(), $list, $data->after ? $data->after : array()); } if($reverse) $list = array_reverse($list); $result = array(); foreach($list as $child) { if(is_array($child)) { $childList = findInNode($selectors, $child, $first, $reverse, $onlyContent); if(!empty($childList)) { if($first) return $childList; $result = array_merge($result, $childList); } continue; } if(!($child instanceof node)) continue; if($child->is($selectors) && $child->type() !== 'item') { if($child->parent && ($child->parent->removed || ($child->parent instanceof wg && $child->parent->type() !== 'item' && $child->parent->is($selectors)))) continue; $result[$child->gid] = $child; if($first) return $result; } $childList = findInNode($selectors, $child, $first, $reverse, $onlyContent); if(!empty($childList)) { if($first) return $childList; $result = array_merge($result, $childList); } } return $result; } function renderToHtml(mixed ...$items): string { $html = ''; foreach($items as $item) { if(is_array($item)) { $html .= renderToHtml(...$item); continue; } if($item instanceof node || (is_object($item) && method_exists($item, 'render'))) { $html .= $item->render(); continue; } if(is_object($item) && isset($item->html)) { $html .= $item->html; continue; } if(!is_string($item)) $item = strval($item); $html .= strval($item); } return $html; } function prebuild(array $items, ?node $parent = null) { foreach($items as $index => $item) { if(is_array($item)) { $items[$index] = prebuild($item, $parent); continue; } if(!($item instanceof node)) continue; if($parent) $item->parent = $parent; $item->prebuild(); } return $items; } /** * Parse the props definition. * * @param array $definition - The props definition. * @param array $parentProps - The parent props. * @param array $defaultValues - The default values. * @return array */ function parsePropsMap(array $definition, array $parentProps = array(), array $defaultValues = array()) { $props = $parentProps; foreach($parentProps as $parentProp) { $name = $parentProp['name']; if(isset($defaultValues[$name])) $parentProp['default'] = $defaultValues[$name]; $props[$name] = $parentProp; } foreach($definition as $name => $value) { $prop = parseProp($value, is_string($name) ? $name : null); $name = $prop['name']; if(isset($defaultValues[$name])) { $prop['default'] = $defaultValues[$name]; } elseif(!isset($prop['default']) && isset($parentProps[$name]['default']) && $parentProps[$name]['default']) { $prop['default'] = $parentProps[$name]['default']; } $props[$name] = $prop; } return $props; } /** * Parse the prop definition. * * @param string|array $definition - The prop definition. * @param string|null $name - The prop name. * @return array */ function parseProp(string|array $definition, ?string $name = null) { $optional = false; $type = 'mixed'; $prop = array(); if(is_string($definition)) $definition = trim($definition); /* Parse definition like `'name?: type1|type2="default"'` . */ if(!$name && is_string($definition)) { if(str_contains($definition, ':')) { list($name, $definition) = explode(':', $definition, 2); } else { $name = $definition; $definition = ''; } $name = trim($name); if(str_ends_with($name, '?')) { $name = substr($name, 0, strlen($name) - 1); $optional = true; } } /* Parse definition like `'name' => '?type1|type2="default"'` . */ if(is_array($definition)) { if(isset($definition['type'])) $type = $definition['type']; if(isset($definition['default'])) $prop['default'] = $definition['default']; if(isset($definition['optional'])) $optional = $definition['optional']; } else if(is_string($definition)) { if(str_contains($definition, '=')) { list($type, $default) = explode('=', $definition, 2); if(strlen($default)) $prop['default'] = json_decode(trim($default)); } else { $type = $definition; } } $type = trim($type); if(str_starts_with($type, '?')) { $type = substr($type, 1); $optional = true; } $typeList = explode('|', $type); if(in_array('null', $typeList) || in_array('mixed', $typeList)) { $optional = true; } elseif($optional) { array_unshift($typeList, 'null'); } $prop['name'] = $name; $prop['type'] = implode('|', $typeList); $prop['optional'] = $optional || (isset($prop['default']) && $prop['default'] !== null); return $prop; }