diff --git a/packages/interpreter/build.rs b/packages/interpreter/build.rs index af67eb3cc..bae015f06 100644 --- a/packages/interpreter/build.rs +++ b/packages/interpreter/build.rs @@ -13,7 +13,7 @@ fn main() { return; } - panic!("Hashes match, no need to update bindings. {expected} != {hash}",); + // panic!("Hashes match, no need to update bindings. {expected} != {hash}",); // Otherwise, generate the bindings and write the new hash to disk // Generate the bindings for both native and web @@ -21,7 +21,7 @@ fn main() { gen_bindings("native", "native"); gen_bindings("core", "core"); - std::fs::write("src/js/hash.txt", hash.to_string()).unwrap(); + std::fs::write("src/js/hash.txt", hash).unwrap(); } /// Hashes the contents of a directory @@ -35,7 +35,7 @@ fn hash_ts_files() -> String { ]; for file in files { - out = format!("{out}{:?}", md5::compute(file)); + out = format!("{out}{file}"); } out diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 0eab9f367..c37c153a7 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1,557 @@ -3bd5a1310ebd728bdedb486881d5ad99151cf07b0006de52240dfd757ea3353e90df122a7ae681f33ff620cf15c47a20 \ No newline at end of file +export { setAttributeInner } from "./set_attribute"; +export { retrieveFormValues } from "./form"; +// This file provides an extended variant of the interpreter used for desktop and liveview interaction +// +// This file lives on the renderer, not the host. It's basically a polyfill over functionality that the host can't +// provide since it doesn't have access to the dom. + +import { BaseInterpreter, NodeId } from "./core"; +import { SerializedEvent, serializeEvent } from "./serialize"; + +// okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope +// we want to extend it, and it technically extends base intepreter. To make typescript happy, +// we're going to bind the JSChannel_ object to the JSChannel object, and then extend it +var JSChannel_: typeof BaseInterpreter; + +// @ts-ignore - this is coming from the host +if (RawInterpreter !== undefined && RawInterpreter !== null) { + // @ts-ignore - this is coming from the host + JSChannel_ = RawInterpreter; +}; + +export class NativeInterpreter extends JSChannel_ { + intercept_link_redirects: boolean; + ipc: any; + editsPath: string; + + // eventually we want to remove liveview and build it into the server-side-events of fullstack + // however, for now we need to support it since SSE in fullstack doesn't exist yet + liveview: boolean; + + constructor(editsPath: string) { + super(); + this.editsPath = editsPath; + } + + initialize(root: HTMLElement): void { + this.intercept_link_redirects = true; + this.liveview = false; + + // attach an event listener on the body that prevents file drops from navigating + // this is because the browser will try to navigate to the file if it's dropped on the window + window.addEventListener("dragover", function (e) { + // // check which element is our target + if (e.target instanceof Element && e.target.tagName != "INPUT") { + e.preventDefault(); + } + }, false); + + window.addEventListener("drop", function (e) { + let target = e.target; + + if (!(target instanceof Element)) { + return; + } + + // Dropping a file on the window will navigate to the file, which we don't want + e.preventDefault(); + }, false); + + // attach a listener to the route that listens for clicks and prevents the default file dialog + window.addEventListener("click", (event) => { + const target = event.target; + if (target instanceof HTMLInputElement && target.getAttribute("type") === "file") { + // Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it + let target_id = getTargetId(target); + if (target_id !== null) { + const message = this.serializeIpcMessage("file_dialog", { + event: "change&input", + accept: target.getAttribute("accept"), + directory: target.getAttribute("webkitdirectory") === "true", + multiple: target.hasAttribute("multiple"), + target: target_id, + bubbles: event.bubbles, + }); + this.ipc.postMessage(message); + } + + // Prevent default regardless - we don't want file dialogs and we don't want the browser to navigate + event.preventDefault(); + } + }); + + + // @ts-ignore - wry gives us this + this.ipc = window.ipc; + + // make sure we pass the handler to the base interpreter + const handler: EventListener = (event) => this.handleEvent(event, event.type, true); + super.initialize(root, handler); + } + + serializeIpcMessage(method: string, params = {}) { + return JSON.stringify({ method, params }); + } + + scrollTo(id: NodeId, behavior: ScrollBehavior) { + const node = this.nodes[id]; + if (node instanceof HTMLElement) { + node.scrollIntoView({ behavior }); + } + } + + getClientRect(id: NodeId): { type: string; origin: number[]; size: number[]; } | undefined { + const node = this.nodes[id]; + if (node instanceof HTMLElement) { + const rect = node.getBoundingClientRect(); + return { + type: "GetClientRect", + origin: [rect.x, rect.y], + size: [rect.width, rect.height], + }; + } + } + + setFocus(id: NodeId, focus: boolean) { + const node = this.nodes[id]; + + if (node instanceof HTMLElement) { + if (focus) { + node.focus(); + } else { + node.blur(); + } + } + } + + // ignore the fact the base interpreter uses ptr + len but we use array... + // @ts-ignore + loadChild(array: number[]) { + // iterate through each number and get that child + let node = this.stack[this.stack.length - 1]; + + for (let i = 0; i < array.length; i++) { + let end = array[i]; + for (node = node.firstChild; end > 0; end--) { + node = node.nextSibling; + } + } + + return node; + } + + appendChildren(id: NodeId, many: number) { + const root = this.nodes[id]; + const els = this.stack.splice(this.stack.length - many); + + for (let k = 0; k < many; k++) { + root.appendChild(els[k]); + } + } + + handleEvent(event: Event, name: string, bubbles: boolean) { + const target = event.target!; + const realId = getTargetId(target)!; + const contents = serializeEvent(event, target); + + // Handle the event on the virtualdom and then preventDefault if it also preventsDefault + // Some listeners + let body = { + name: name, + data: contents, + element: realId, + bubbles, + }; + + // Run any prevent defaults the user might've set + // This is to support the prevent_default: "onclick" attribute that dioxus has had for a while, but is not necessary + // now that we expose preventDefault to the virtualdom on desktop + // Liveview will still need to use this + this.preventDefaults(event, target); + + // liveview does not have syncronous event handling, so we need to send the event to the host + if (this.liveview) { + // Okay, so the user might've requested some files to be read + if (target instanceof HTMLInputElement && (event.type === "change" || event.type === "input")) { + if (target.getAttribute("type") === "file") { + this.readFiles(target, contents, bubbles, realId, name); + } + } + } else { + + const message = this.serializeIpcMessage("user_event", body); + this.ipc.postMessage(message); + + // // Run the event handler on the virtualdom + // // capture/prevent default of the event if the virtualdom wants to + // const res = handleVirtualdomEventSync(JSON.stringify(body)); + + // if (res.preventDefault) { + // event.preventDefault(); + // } + + // if (res.stopPropagation) { + // event.stopPropagation(); + // } + } + } + + + + // This should: + // - prevent form submissions from navigating + // - prevent anchor tags from navigating + // - prevent buttons from submitting forms + // - let the virtualdom attempt to prevent the event + preventDefaults(event: Event, target: EventTarget) { + let preventDefaultRequests: string | null = null; + + // Some events can be triggered on text nodes, which don't have attributes + if (target instanceof Element) { + preventDefaultRequests = target.getAttribute(`dioxus-prevent-default`); + } + + if (preventDefaultRequests && preventDefaultRequests.includes(`on${event.type}`)) { + event.preventDefault(); + } + + if (event.type === "submit") { + event.preventDefault(); + } + + // Attempt to intercept if the event is a click + if (target instanceof Element && event.type === "click") { + this.handleClickNavigate(event, target, preventDefaultRequests); + } + } + + handleClickNavigate(event: Event, target: Element, preventDefaultRequests: string) { + // todo call prevent default if it's the right type of event + if (!this.intercept_link_redirects) { + return; + } + + // prevent buttons in forms from submitting the form + if (target.tagName === "BUTTON" && event.type == "submit") { + event.preventDefault(); + } + + // If the target is an anchor tag, we want to intercept the click too, to prevent the browser from navigating + let a_element = target.closest("a"); + if (a_element == null) { + return; + } + + event.preventDefault(); + + let elementShouldPreventDefault = + preventDefaultRequests && preventDefaultRequests.includes(`onclick`); + + let aElementShouldPreventDefault = a_element.getAttribute( + `dioxus-prevent-default` + ); + + let linkShouldPreventDefault = + aElementShouldPreventDefault && + aElementShouldPreventDefault.includes(`onclick`); + + if (!elementShouldPreventDefault && !linkShouldPreventDefault) { + const href = a_element.getAttribute("href"); + if (href !== "" && href !== null && href !== undefined) { + this.ipc.postMessage( + this.serializeIpcMessage("browser_open", { href }) + ); + } + } + } + + waitForRequest(headless: boolean) { + fetch(new Request(this.editsPath)) + .then(response => response.arrayBuffer()) + .then(bytes => { + // In headless mode, the requestAnimationFrame callback is never called, so we need to run the bytes directly + if (headless) { + // @ts-ignore + this.run_from_bytes(bytes); + } else { + // @ts-ignore + requestAnimationFrame(() => this.run_from_bytes(bytes)); + } + this.waitForRequest(headless); + }); + } + + + // A liveview only function + // Desktop will intercept the event before it hits this + async readFiles(target: HTMLInputElement, contents: SerializedEvent, bubbles: boolean, realId: NodeId, name: string) { + let files = target.files!; + let file_contents: { [name: string]: number[] } = {}; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + file_contents[file.name] = Array.from( + new Uint8Array(await file.arrayBuffer()) + ); + } + + contents.files = { files: file_contents }; + + const message = this.serializeIpcMessage("user_event", { + name: name, + element: realId, + data: contents, + bubbles, + }); + + this.ipc.postMessage(message); + } +} + +type EventSyncResult = { + preventDefault: boolean; + stopPropagation: boolean; + stopImmediatePropagation: boolean; + filesRequested: boolean; +}; + +// This function sends the event to the virtualdom and then waits for the virtualdom to process it +// +// However, it's not really suitable for liveview, because it's synchronous and will block the main thread +// We should definitely consider using a websocket if we want to block... or just not block on liveview +// Liveview is a little bit of a tricky beast +function handleVirtualdomEventSync(contents: string): EventSyncResult { + // Handle the event on the virtualdom and then process whatever its output was + const xhr = new XMLHttpRequest(); + + // Serialize the event and send it to the custom protocol in the Rust side of things + xhr.timeout = 1000; + xhr.open("GET", "/handle/event.please", false); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(contents); + + // Deserialize the response, and then prevent the default/capture the event if the virtualdom wants to + return JSON.parse(xhr.responseText); +} + +function getTargetId(target: EventTarget): NodeId | null { + // Ensure that the target is a node, sometimes it's nota + if (!(target instanceof Node)) { + return null; + } + + let ourTarget = target; + let realId = null; + + while (realId == null) { + if (ourTarget === null) { + return null; + } + + if (ourTarget instanceof Element) { + realId = ourTarget.getAttribute(`data-dioxus-id`); + } + + ourTarget = ourTarget.parentNode; + } + + return parseInt(realId); +} + + +// function applyFileUpload() { +// let inputs = document.querySelectorAll("input"); +// for (let input of inputs) { +// if (!input.getAttribute("data-dioxus-file-listener")) { +// // prevent file inputs from opening the file dialog on click +// const type = input.getAttribute("type"); +// if (type === "file") { +// input.setAttribute("data-dioxus-file-listener", true); +// input.addEventListener("click", (event) => { +// let target = event.target; +// let target_id = find_real_id(target); +// if (target_id !== null) { +// const send = (event_name) => { +// const message = window.interpreter.serializeIpcMessage("file_diolog", { accept: target.getAttribute("accept"), directory: target.getAttribute("webkitdirectory") === "true", multiple: target.hasAttribute("multiple"), target: parseInt(target_id), bubbles: event_bubbles(event_name), event: event_name }); +// window.ipc.postMessage(message); +// }; +// send("change&input"); +// } +// event.preventDefault(); +// }); +// } +// } +// } +// The root interpreter class that holds state about the mapping between DOM and VirtualDom +// This always lives in the JS side of things, and is extended by the native and web interpreters + +import { setAttributeInner } from "./set_attribute"; + +export type NodeId = number; + +export class BaseInterpreter { + // non bubbling events listen at the element the listener was created at + global: { + [key: string]: { active: number, callback: EventListener } + }; + // bubbling events can listen at the root element + local: { + [key: string]: { + [key: string]: EventListener + } + }; + + root: HTMLElement; + handler: EventListener; + nodes: Node[]; + stack: Node[]; + templates: { + [key: number]: Node[] + }; + + // sledgehammer is generating this... + m: any; + + constructor() { } + + initialize(root: HTMLElement, handler: EventListener | null = null) { + this.global = {}; + this.local = {}; + this.root = root; + + this.nodes = [root]; + this.stack = [root]; + this.templates = {}; + + if (handler) { + this.handler = handler; + } + } + + createListener(event_name: string, element: HTMLElement, bubbles: boolean) { + if (bubbles) { + if (this.global[event_name] === undefined) { + this.global[event_name] = { active: 1, callback: this.handler }; + this.root.addEventListener(event_name, this.handler); + } else { + this.global[event_name].active++; + } + } else { + const id = element.getAttribute("data-dioxus-id"); + if (!this.local[id]) { + this.local[id] = {}; + } + element.addEventListener(event_name, this.handler); + } + } + + removeListener(element: HTMLElement, event_name: string, bubbles: boolean) { + if (bubbles) { + this.removeBubblingListener(event_name); + } else { + this.removeNonBubblingListener(element, event_name); + } + } + + removeBubblingListener(event_name: string) { + this.global[event_name].active--; + if (this.global[event_name].active === 0) { + this.root.removeEventListener(event_name, this.global[event_name].callback); + delete this.global[event_name]; + } + } + + removeNonBubblingListener(element: HTMLElement, event_name: string) { + const id = element.getAttribute("data-dioxus-id"); + delete this.local[id][event_name]; + if (Object.keys(this.local[id]).length === 0) { + delete this.local[id]; + } + element.removeEventListener(event_name, this.handler); + } + + removeAllNonBubblingListeners(element: HTMLElement) { + const id = element.getAttribute("data-dioxus-id"); + delete this.local[id]; + } + + getNode(id: NodeId): Node { + return this.nodes[id]; + } + + appendChildren(id: NodeId, many: number) { + const root = this.nodes[id]; + const els = this.stack.splice(this.stack.length - many); + for (let k = 0; k < many; k++) { + root.appendChild(els[k]); + } + } + + loadChild(ptr: number, len: number): Node { + // iterate through each number and get that child + let node = this.stack[this.stack.length - 1] as Node; + let ptr_end = ptr + len; + + for (; ptr < ptr_end; ptr++) { + let end = this.m.getUint8(ptr); + for (node = node.firstChild; end > 0; end--) { + node = node.nextSibling; + } + } + + return node; + } + + saveTemplate(nodes: HTMLElement[], tmpl_id: number) { + this.templates[tmpl_id] = nodes; + } + + hydrate(ids: { [key: number]: number }) { + const hydrateNodes = document.querySelectorAll('[data-node-hydration]'); + + for (let i = 0; i < hydrateNodes.length; i++) { + const hydrateNode = hydrateNodes[i] as HTMLElement; + const hydration = hydrateNode.getAttribute('data-node-hydration'); + const split = hydration!.split(','); + const id = ids[parseInt(split[0])]; + + this.nodes[id] = hydrateNode; + + if (split.length > 1) { + // @ts-ignore + hydrateNode.listening = split.length - 1; + hydrateNode.setAttribute('data-dioxus-id', id.toString()); + for (let j = 1; j < split.length; j++) { + const listener = split[j]; + const split2 = listener.split(':'); + const event_name = split2[0]; + const bubbles = split2[1] === '1'; + this.createListener(event_name, hydrateNode, bubbles); + } + } + } + + const treeWalker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_COMMENT, + ); + + let currentNode = treeWalker.nextNode(); + + while (currentNode) { + const id = currentNode.textContent!; + const split = id.split('node-id'); + + if (split.length > 1) { + this.nodes[ids[parseInt(split[1])]] = currentNode.nextSibling; + } + + currentNode = treeWalker.nextNode(); + } + } + + setAttributeInner(node: HTMLElement, field: string, value: string, ns: string) { + setAttributeInner(node, field, value, ns); + } +} +