- First working version, tagging to 1.0

This commit is contained in:
Dave M. 2026-01-16 16:20:25 -05:00
parent af4fa89127
commit e0e53e1aa2
12 changed files with 457 additions and 124 deletions

View File

@ -1,2 +1,2 @@
import { UiLive } from "./ui-live.mjs";
window.customElements.define("ui-live", UiLive);
import {UiLive} from "./ui-live.mjs";
window.customElements.get('ui-live') || window.customElements.define("ui-live", UiLive);

File diff suppressed because one or more lines are too long

View File

@ -1,27 +1,47 @@
export class Events {
#object;
constructor(object) {
constructor(object, swapping) {
this.#object = object;
if ( null === this.object.parentElement.closest('ui-live') ) {
if ( object.url || null === this.object.parentElement.closest('ui-live') ) {
this.attachLinks();
this.attachForms();
this.attachDataLiveUi();
}
if (! swapping) {
this.autoloadUrl();
}
}
autoloadUrl() {
this.object.url && this.object.emit("live:autoload", {
element: this.object,
url: this.object.url,
});
}
attachLinks() {
const links = this.object.querySelectorAll("a[href]");
for (const link of links) {
if (link.dataset.uiLive === undefined) {
const href = link.getAttribute('href');
if (href.length && href.substring(0, 1) !== '#') {
link.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.object.emit("live:navigate", { element: this.object, "url" : href, "link" : link });
this.object.emit("live:navigate", {
element: this.object,
url: href,
link: link,
sourceEvent: ev
});
});
}
}
}
}
@ -35,17 +55,51 @@ export class Events {
form.addEventListener("submit", (ev) => {
ev.preventDefault();
this.object.emit("live:form-submit", { element: this.object, "form" : form, "method": method, submitter: ev.submitter });
const name = ev.submitter.getAttribute('name'),
value = ev.submitter.getAttribute('value');
if (name && value) {
form.insertAdjacentHTML( 'beforeend', `<input type='hidden' name='${name}' value='${value}'>` );
}
this.object.emit("live:form-submit", { element: this.object, form: form, method: method, sourceEvent: ev });
});
if (method === "GET") {
form.querySelectorAll("input,select").forEach((ele) => {
ele.addEventListener("change", (ev) => form.submit());
ele.addEventListener("change", (ev) => form.requestSubmit());
});
}
}
}
attachDataLiveUi() {
const elements = this.object.querySelectorAll("[data-ui-live]");
for (const element of elements) {
switch(element.tagName.toUpperCase()) {
case "TEXTAREA":
case "SELECT":
case "INPUT":
element.addEventListener("change", (ev) =>
this.object.emit("live:input-change", { element: element, source: this.object, submitter: element, sourceEvent: ev })
);
break;
default:
element.addEventListener("click", (ev) => {
ev.stopPropagation();
ev.preventDefault();
this.object.emit("live:click", { element: element, source: this.object, submitter: element, sourceEvent: ev })
});
break;
}
}
}
get object() {
return this.#object;
}

View File

@ -24,6 +24,10 @@ export class UiLive extends HTMLElement {
connectedCallback() {
documentManipulatorObject.registerLiveElement(this);
this.on("live:swap", () => new Events(this, true));
new Events(this, false);
if (this.pool) {
const self = {};
self[this.name] = this;
@ -31,17 +35,12 @@ export class UiLive extends HTMLElement {
this.intervalId = setInterval(() => documentManipulatorObject.fetchFromUrl(this.config.url, self), this.pool * 1000);
}
this.on("live:swap", () => new Events(this));
new Events(this);
this.#connected = true;
console.debug("component connected: ", this);
}
adoptedCallback() {
console.log("I was adopted ?");
}
on(type, listener, options) {
@ -71,9 +70,24 @@ export class UiLive extends HTMLElement {
this.emit("live:swap");
}
/** @return string */
get url() {
return this.dataset.src;
}
/** @return string */
set url(value) {
return this.dataset.src = value;
}
/** @return string */
get selector() {
return this.dataset.selector;
}
/** @return string */
get name() {
return this.dataset["name"];
return this.dataset.name;
}
/** @return string */
@ -98,10 +112,10 @@ export class UiLive extends HTMLElement {
/** @return int | undefined */
get pool() {
if (isNaN(this.dataset['pool'])) {
if (isNaN(this.dataset.pool)) {
return undefined;
}
return this.dataset["pool"];
return this.dataset.pool;
}
}

View File

@ -1,6 +1,8 @@
<?php
use Ulmus\ConnectionAdapter;
use Lean\Live\Live;
use function DI\autowire;
putenv('LEAN_API_PROJECT_PATH=' . $path = dirname(__DIR__, 2));
@ -22,10 +24,16 @@ return [
'order' => 10
],
],
'extensions' => [
\Lean\Live\Picea\Live::class,
],
],
'routes' => [
#'Lean\\Live\\Controller' => implode(DIRECTORY_SEPARATOR, [ $path, "src", "Controller", "" ]),
],
],
Live::class => autowire(Live::class),
];

34
src/Html.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace Lean\Live;
class Html
{
public static function nodeToArray(\Dom\Node $node) : array
{
$output = [
'nodeName' => $node->nodeName,
'nodeValue' => $node->nodeValue,
'nodeType' => $node->nodeType,
'attributes' => [],
'childNodes' => []
];
if ($node->nodeType === XML_ELEMENT_NODE && $node->hasAttributes()) {
foreach ($node->attributes as $attribute) {
$output['attributes'][$attribute->name] = $attribute->value;
}
}
if ($node->hasChildNodes()) {
foreach ($node->childNodes as $childNode) {
if ($childNode->nodeType === XML_TEXT_NODE && trim($childNode->nodeValue) === '') {
continue;
}
$output['childNodes'][] = static::nodeToArray($childNode);
}
}
return array_filter($output);
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace Lean\Live\Lib;
class Html
{
public static function nodeToArray(\Dom\Node $node) : array
{
return $list ?? [];
}
}

89
src/Live.php Normal file
View File

@ -0,0 +1,89 @@
<?php
namespace Lean\Live;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class Live
{
public array $variablesObjects = [];
protected array $options = [];
public readonly array $params;
public bool $ignoreHistoryModification {
get => $this->options['ignoreHistoryModification'] ?? false;
set => $this->options['ignoreHistoryModification'] = $value;
}
public function fromResponse(ServerRequestInterface $request) : void {
if ( $encodedHeader = $request->getHeaderLine("X-Ui-Live") ) {
$this->params = json_decode($encodedHeader, true);
foreach($this->params['vars'] ?? [] as $variables) {
parse_str(implode('&', $variables['values']), $vars);
$this->variablesObjects[$variables['source']] = new Variables($variables['source'], $vars, true);
}
}
}
public function catch(ResponseInterface $response) : bool
{
return ! empty($this->params) &&
in_array($response->getStatusCode(), [ 200, 201, ], true) &&
str_starts_with($response->getHeaderLine('content-type'), 'text/html');
}
public function render($stream) : array
{
$dom = \DOM\HTMLDocument::createFromString($stream->getContents(), 32);
if ($this->params['elements'] ?? false) {
$list = array_map(fn($e) => $e['name'], $this->params['elements']);
if ($list) {
foreach ($list as $name) {
$node = $dom->querySelector("ui-live[data-name=\"{$name}\"]");
if ($node && !$this->isAlreadyRendering($node, $list)) {
$elements[$name] = $this->renderElement($node);
}
}
}
}
return array_filter([
'head' => empty($this->params['includeHead']) ? false : Html::nodeToArray($dom->querySelector("head")),
'elements' => $elements ?? false,
'options' => $this->options,
]);
}
public function getVariableBag(string $name) : mixed
{
return $this->variablesObjects[$name] ?? new Variables("", [], false);
}
protected function isAlreadyRendering(\DOM\Element $element, array $list) : bool
{
$parent = $element->parentElement;
do {
if (strtolower($parent->tagName) === "ui-live") {
if ($parent->hasAttribute('data-name') && in_array($parent->getAttribute('data-name'), $list)) {
return true;
}
}
} while($parent = $parent->parentElement);
return false;
}
protected function renderElement(\DOM\Element $element) : string
{
return $element->innerHTML;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace Lean\Live;
use DI\Attribute\Inject;
trait LiveControllerTrait
{
#[Inject]
protected Live $live;
}

View File

@ -4,6 +4,8 @@ namespace Lean\Live\Middleware;
use DI\Attribute\Inject;
use Lean\Factory\HttpFactoryInterface;
use Lean\Live\Html;
use Lean\Live\Live;
use Psr\Http\{Message\ResponseInterface,
Message\ServerRequestInterface,
Server\MiddlewareInterface,
@ -14,75 +16,25 @@ class RenderPartOf implements MiddlewareInterface
#[Inject]
protected HttpFactoryInterface $factory;
#[Inject]
protected Live $live;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->live->fromResponse($request);
$handling = $handler->handle($request);
if (
in_array($handling->getStatusCode(), [ 200, 201, ], true) &&
str_starts_with($handling->getHeaderLine('content-type'), 'text/html')
) {
# Subject to change
$uiLiveRequest = $request->getHeaderLine("X-Ui-Live");
if ($uiLiveRequest) {
$decoded = json_decode($uiLiveRequest, true);
$dom = \DOM\HTMLDocument::createFromString($handling->getBody()->getContents(), 32);
$list = array_map(fn($e) => $e['name'], $decoded['elements']);
if ($list) {
foreach ($list as $name) {
$node = $dom->querySelector("ui-live[data-name=\"{$name}\"]");
if ($node && !$this->isAlreadyRendering($node, $list)) {
$elements[$name] = $this->renderElement($node);
}
}
}
if ($decoded['includeHead'] ?? false) {
foreach($dom->querySelector("head")->childNodes as $node) {
if ($node instanceof \DOM\HTMLElement) {
$head[] = $dom->saveHTML($node);
}
}
}
$jsonResponse = array_filter([
'head' => $head ?? false,
'elements' => $elements ?? false,
]);
if ($elements) {
return $this->factory->createJsonResponse($jsonResponse, $handling->getStatusCode());
if ($this->live->catch($handling)) {
if ( $response = $this->live->render($handling->getBody())) {
return $this->factory->createJsonResponse($response, $handling->getStatusCode());
}
else {
return $this->factory->createEmptyResponse();
}
}
}
return $handling;
}
protected function isAlreadyRendering(\DOM\Element $element, array $list) : bool
{
$parent = $element->parentElement;
do {
if (strtolower($parent->tagName) === "ui-live") {
if ($parent->hasAttribute('data-name') && in_array($parent->getAttribute('data-name'), $list)) {
return true;
}
}
} while($parent = $parent->parentElement);
return false;
}
protected function renderElement(\DOM\Element $element) : string
{
return $element->innerHTML;
}
}

View File

@ -14,7 +14,8 @@ class Live implements Extension, FunctionExtension {
use ExtensionTrait;
public function __construct(
protected ServerRequestInterface $request
protected ServerRequestInterface $request,
protected \Lean\Live\Live $live,
) {}
public function parse(\Picea\Compiler\Context &$context, ?string $arguments, string $token, array $options = []) : string { }
@ -22,12 +23,7 @@ class Live implements Extension, FunctionExtension {
public function exportFunctions(): array
{
return [
"live" => [ $this, 'liveElement' ],
"live" => fn(string $source) => $this->live->getVariableBag($source),
];
}
public function liveElement(string $inputName) : void
{
}
}

32
src/Variables.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace Lean\Live;
class Variables implements \ArrayAccess
{
public function __construct(
public readonly string $name,
public readonly array $variables,
public readonly bool $active,
) {}
public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->variables);
}
public function offsetGet(mixed $offset): mixed
{
return $this->variables[$offset] ?? null;
}
public function offsetSet(mixed $offset, mixed $value): void
{
throw new \LogicException("Live variable are read-only and cannot be redefined");
}
public function offsetUnset(mixed $offset): void
{
unset($this->variables[$offset]);
}
}