- WIP still on the live module

This commit is contained in:
Dave M. 2025-11-11 13:35:05 -05:00
parent b7cd369c12
commit af4fa89127
7 changed files with 411 additions and 77 deletions

View File

@ -0,0 +1,137 @@
class DocumentManipulator {
#elements = {};
#window;
constructor(window) {
this.window = window;
this.window.addEventListener("popstate", (event) => {
if (event.state && event.state.wasFetched) {
this.fetchFromUrl(window.location.href, this.elements);
}
});
}
registerLiveElement(element) {
if (this.elements[element.name] === undefined) {
this.elements[element.name] = element;
element.on("live:navigate", this.navigateTo.bind(this))
element.on("live:form-submit", this.formSubmit.bind(this))
}
}
unregisterLiveElement(element) {
}
navigateTo(event) {
this.fetchFromUrl(event.detail.url, this.elements, {}, { includeHead: true });
window.history.pushState({ wasFetched: true },"", event.detail.url);
}
formSubmit(event) {
const url = new URL(event.detail.form.getAttribute('action') ? event.detail.form.getAttribute('action') : window.document.location);
const options = {
method: event.detail.method,
headers: event.detail.headers ? event.detail.headers : {},
};
const formData = new FormData(event.detail.form);
if (options.method !== "GET") {
options.body = formData;
if (event.detail.submitter.hasAttribute('name')) {
options.body.append(event.detail.submitter.getAttribute('name'), event.detail.submitter.getAttribute('value'));
}
}
else {
for (const [key, value] of formData.entries()) {
url.searchParams.set(key, value);
}
}
this.fetchFromUrl(url.toString(), [ event.detail.element.name ], options);
if (window.document.location !== url) {
window.history.pushState({ wasFetched: true }, "", url);
}
}
fetchFromUrl(url, elements, options, liveHeaders) {
liveHeaders = liveHeaders ? liveHeaders : {};
options = options ? options : {};
liveHeaders.elements = liveHeaders.elements ? liveHeaders.elements : [];
if (Array.isArray(elements)) {
for (const key of elements) {
liveHeaders.elements.push({name: key});
}
}
else {
for (const key in elements) {
liveHeaders.elements.push({name: key});
}
}
options.headers = options.headers ? options.headers : {};
options.headers['X-Ui-Live'] = JSON.stringify(liveHeaders);
fetch(url, {... {
method: "GET",
}, ...( options ? options : {} ) })
.then(response => {
if (response.redirected) {
this.navigateTo({detail: { url : response.url, }});
return false;
}
return response.json();
})
.then(json => {
if (json) {
return this.render(json, url)
}
})
.catch((error) => {
console.error(error);
});
}
render(json, url) {
for (let key in json.elements) {
const elem = this.elements[key];
if (elem) {
elem.swapHtml(json.elements[key]);
}
}
}
/* Making sure we're not updating a component which is already updated by a parent node */
parentsOnly(elements) {
}
get elements() {
return this.#elements;
}
set elements(value) {
return this.#elements = value;
}
get window() {
return this.#window;
}
set window(value) {
return this.#window = value;
}
}
export const documentManipulatorObject = new DocumentManipulator(window);

View File

@ -0,0 +1,52 @@
export class Events {
#object;
constructor(object) {
this.#object = object;
if ( null === this.object.parentElement.closest('ui-live') ) {
this.attachLinks();
this.attachForms();
}
}
attachLinks() {
const links = this.object.querySelectorAll("a[href]");
for (const link of links) {
const href = link.getAttribute('href');
if (href.length && href.substring(0, 1) !== '#') {
link.addEventListener("click", (ev) => {
ev.preventDefault();
this.object.emit("live:navigate", { element: this.object, "url" : href, "link" : link });
});
}
}
}
attachForms() {
const forms = this.object.querySelectorAll("form");
for (const form of forms) {
const method = form.hasAttribute('method') ? form.getAttribute('method').toUpperCase() : "GET";
form.addEventListener("submit", (ev) => {
ev.preventDefault();
this.object.emit("live:form-submit", { element: this.object, "form" : form, "method": method, submitter: ev.submitter });
});
if (method === "GET") {
form.querySelectorAll("input,select").forEach((ele) => {
ele.addEventListener("change", (ev) => form.submit());
});
}
}
}
get object() {
return this.#object;
}
}

View File

@ -1,54 +1,74 @@
import { Webcomponent } from "../../webcomponent/module/webcomponent.js";
import { Hourglass } from "../../webcomponent/hourglass.js";
import { Events } from "./events.mjs";
import { documentManipulatorObject } from "./document-manipulator.mjs";
const config = {
export class UiLive extends HTMLElement {
#config = {
url: window.location.href,
template: {
fetch : false
fetch : true
}
};
class UiLive extends Webcomponent {
constructor() {
super(config);
this.init();
}
init() {
#intervalId;
#connected = false;
disconnectedCallback() {
this.intervalId && clearTimeout(this.intervalId);
documentManipulatorObject.unregisterLiveElement(this);
console.debug("component disconnected: ", this);
}
connectedCallback() {
documentManipulatorObject.registerLiveElement(this);
if (this.pool) {
new Hourglass(this.fetchAndRender.bind(this), {
timer: this.pool * 1000,
start: true,
});
}
const self = {};
self[this.name] = this;
this.intervalId = setInterval(() => documentManipulatorObject.fetchFromUrl(this.config.url, self), this.pool * 1000);
}
fetchAndRender() {
this.classList.add("live-loading");
this.on("live:swap", () => new Events(this));
fetch(this.config.url, {
method: "GET",
headers: {
'X-Ui-Live': JSON.stringify([ { name: this.name } ])
new Events(this);
this.#connected = true;
console.debug("component connected: ", this);
}
}).then( response => response.text() )
.then(html => {
this.swapHtml(html);
})
.catch((error) => {
console.error(error);
});
adoptedCallback() {
console.log("I was adopted ?");
}
on(type, listener, options) {
this.addEventListener(type, listener, options);
return this;
}
off(type, listener, options) {
this.removeEventListener(type, listener, options);
return this;
}
emit(name, detail, bubbles, cancelable, composed) {
this.dispatchEvent(new CustomEvent(name, {
detail: detail ? detail : null,
bubbles: !! bubbles,
cancelable: !! cancelable,
composed: !! composed,
}));
}
swapHtml(html) {
this.innerHTML = ( new DOMParser() )
.parseFromString(html, "text/html")
.querySelector('ui-live[data-name="' + this.name + '"]')
.innerHTML;
this.innerHTML = html;
this.dispatchEvent(new CustomEvent("live:swap"));
this.emit("live:swap");
}
/** @return string */
@ -56,6 +76,26 @@ class UiLive extends Webcomponent {
return this.dataset["name"];
}
/** @return string */
get config() {
return this.#config;
}
/** @return string */
set config(value) {
return this.#config = value;
}
/** @return int | undefined */
get intervalId() {
return this.#intervalId;
}
/** @return int | undefined */
set intervalId(value) {
return this.#intervalId = value;
}
/** @return int | undefined */
get pool() {
if (isNaN(this.dataset['pool'])) {
@ -65,5 +105,3 @@ class UiLive extends Webcomponent {
return this.dataset["pool"];
}
}
export { UiLive };

View File

@ -5,9 +5,9 @@ use Ulmus\ConnectionAdapter;
putenv('LEAN_API_PROJECT_PATH=' . $path = dirname(__DIR__, 2));
return [
'lean.api' => [
'lean.live' => [
'picea' => [
'context' => Lean\Api\View::class,
'context' => Lean\Live\View::class,
'view' => [
[
@ -24,37 +24,8 @@ return [
],
],
'ulmus' => [
'entities' => [ 'Lean\\Api\\Entity' => implode(DIRECTORY_SEPARATOR, [ $path, 'src', 'Entity', '' ]) ],
'adapters' => [
DI\get('lean.api:storage'),
]
],
'tell' => [
'json' => [
[
'path' => implode(DIRECTORY_SEPARATOR, [ $path, "meta", "i18n", "" ]),
'order' => 99,
],
]
],
'routes' => [
'Lean\\Api\\Controller' => implode(DIRECTORY_SEPARATOR, [ $path, "src", "Controller", "" ]),
#'Lean\\Live\\Controller' => implode(DIRECTORY_SEPARATOR, [ $path, "src", "Controller", "" ]),
],
],
'lean.api:storage' => function($c) {
$adapter = new ConnectionAdapter('lean.api', $c->get('config')['ulmus'], false);
$adapter->resolveConfiguration();
return $adapter;
},
Lean\ApplicationStrategy\NotFoundDecoratorInterface::class => DI\autowire(Lean\Api\ApplicationStrategy\NotFoundDecorator::class),
Lean\ApplicationStrategy\MethodNotAllowedInterface::class => DI\autowire(Lean\Api\ApplicationStrategy\MethodNotAllowedDecorator::class),
Lean\Api\Factory\MessageFactoryInterface::class => DI\autowire(Lean\Api\Lib\Message::class),
Lean\Api\Factory\DebugFormFactoryInterface::class => DI\autowire(Lean\Api\Factory\DebugFormFactory::class),
# League\Route\Strategy\ApplicationStrategy::class => DI\autowire(Lean\Api\ApplicationStrategy::class),
];

15
src/Lib/Html.php Normal file
View File

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

View File

@ -0,0 +1,88 @@
<?php
namespace Lean\Live\Middleware;
use DI\Attribute\Inject;
use Lean\Factory\HttpFactoryInterface;
use Psr\Http\{Message\ResponseInterface,
Message\ServerRequestInterface,
Server\MiddlewareInterface,
Server\RequestHandlerInterface};
class RenderPartOf implements MiddlewareInterface
{
#[Inject]
protected HttpFactoryInterface $factory;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$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());
}
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;
}
}

33
src/Picea/Live.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace Lean\Live\Picea;
use Picea\Extension\Extension,
Picea\Extension\ExtensionTrait;
use Picea\Compiler\Context;
use Picea\Extension\FunctionExtension;
use Psr\Http\Message\ServerRequestInterface;
class Live implements Extension, FunctionExtension {
use ExtensionTrait;
public function __construct(
protected ServerRequestInterface $request
) {}
public function parse(\Picea\Compiler\Context &$context, ?string $arguments, string $token, array $options = []) : string { }
public function exportFunctions(): array
{
return [
"live" => [ $this, 'liveElement' ],
];
}
public function liveElement(string $inputName) : void
{
}
}