From af4fa8912798ab5d3b475746b5e075a60d6b0e38 Mon Sep 17 00:00:00 2001 From: Dave Mc Nicoll Date: Tue, 11 Nov 2025 13:35:05 -0500 Subject: [PATCH] - WIP still on the live module --- asset/lean-live/document-manipulator.mjs | 137 +++++++++++++++++++++++ asset/lean-live/events.mjs | 52 +++++++++ asset/lean-live/ui-live.mjs | 128 +++++++++++++-------- meta/definitions/software.php | 35 +----- src/Lib/Html.php | 15 +++ src/Middleware/RenderPartOf.php | 88 +++++++++++++++ src/Picea/Live.php | 33 ++++++ 7 files changed, 411 insertions(+), 77 deletions(-) create mode 100644 asset/lean-live/document-manipulator.mjs create mode 100644 asset/lean-live/events.mjs create mode 100644 src/Lib/Html.php create mode 100644 src/Middleware/RenderPartOf.php create mode 100644 src/Picea/Live.php diff --git a/asset/lean-live/document-manipulator.mjs b/asset/lean-live/document-manipulator.mjs new file mode 100644 index 0000000..bbe5a96 --- /dev/null +++ b/asset/lean-live/document-manipulator.mjs @@ -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); \ No newline at end of file diff --git a/asset/lean-live/events.mjs b/asset/lean-live/events.mjs new file mode 100644 index 0000000..390232e --- /dev/null +++ b/asset/lean-live/events.mjs @@ -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; + } +} \ No newline at end of file diff --git a/asset/lean-live/ui-live.mjs b/asset/lean-live/ui-live.mjs index 4182155..1382dd7 100644 --- a/asset/lean-live/ui-live.mjs +++ b/asset/lean-live/ui-live.mjs @@ -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 = { - url: window.location.href, - template: { - fetch : false - } -}; - -class UiLive extends Webcomponent { - - constructor() { - super(config); - - this.init(); - } - - init() { - if (this.pool) { - new Hourglass(this.fetchAndRender.bind(this), { - timer: this.pool * 1000, - start: true, - }); +export class UiLive extends HTMLElement { + #config = { + url: window.location.href, + template: { + fetch : true } } - fetchAndRender() { - this.classList.add("live-loading"); + #intervalId; - fetch(this.config.url, { - method: "GET", - headers: { - 'X-Ui-Live': JSON.stringify([ { name: this.name } ]) - } - }).then( response => response.text() ) - .then(html => { - this.swapHtml(html); - }) - .catch((error) => { - console.error(error); - }); + #connected = false; + + disconnectedCallback() { + this.intervalId && clearTimeout(this.intervalId); + + documentManipulatorObject.unregisterLiveElement(this); + + console.debug("component disconnected: ", this); + } + + connectedCallback() { + documentManipulatorObject.registerLiveElement(this); + + if (this.pool) { + const self = {}; + self[this.name] = this; + + 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) { + 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'])) { @@ -64,6 +104,4 @@ class UiLive extends Webcomponent { return this.dataset["pool"]; } -} - -export { UiLive }; \ No newline at end of file +} \ No newline at end of file diff --git a/meta/definitions/software.php b/meta/definitions/software.php index 8847256..2501dc5 100644 --- a/meta/definitions/software.php +++ b/meta/definitions/software.php @@ -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), ]; \ No newline at end of file diff --git a/src/Lib/Html.php b/src/Lib/Html.php new file mode 100644 index 0000000..fd25a7e --- /dev/null +++ b/src/Lib/Html.php @@ -0,0 +1,15 @@ +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; + } +} diff --git a/src/Picea/Live.php b/src/Picea/Live.php new file mode 100644 index 0000000..f4d1ce4 --- /dev/null +++ b/src/Picea/Live.php @@ -0,0 +1,33 @@ + [ $this, 'liveElement' ], + ]; + } + + public function liveElement(string $inputName) : void + { + + } +}