extend interpreter base class with platform methods

This commit is contained in:
Jonathan Kelley committed 2024-02-27 12:31:11 -08:00
1 parent 7f60010c1e
commit b5447c162f
27 files changed
+1394 -1371

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
function retrieveFormValues() {
}
@@ -0,0 +1,82 @@
// 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
// todo: we want to make these vars that the interpreter uses to be isolated so we can have multiple interpreters
export let m: DataView; // p, ls, d, t, op, i, e, z, metaflags;
export class Interpreter {
// 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: Element;
handler: EventListener;
nodes: Element[];
stack: Element[];
templates: {
[key: string]: Element[]
};
constructor(root: Element, handler: EventListener) {
this.root = root;
this.nodes = [root];
this.stack = [root];
this.global = {};
this.local = {};
this.handler = handler;
}
createListener(event_name: string, element: Element, 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: Element, 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: Element, 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: Element) {
const id = element.getAttribute("data-dioxus-id");
delete this.local[id];
}
}
@@ -0,0 +1,286 @@
// This file provides an extended variant of the interpreter used for desktop and liveview interaction
//
//
import { Interpreter } from "./interpreter_core";
export class NativeInterpreter extends Interpreter {
intercept_link_redirects: boolean;
constructor(root: Element) {
super(root, (event) => handler(event, this, event.type, true));
}
serializeIpcMessage(method: string, params = {}) {
return JSON.stringify({ method, params });
}
scrollTo(id: number, behavior: ScrollBehavior) {
const node = this.nodes[id];
if (!(node instanceof HTMLElement)) {
return false;
}
node.scrollIntoView({
behavior: behavior,
});
return true;
}
getClientRect(id: number) {
const node = this.nodes[id];
if (!node) {
return;
}
const rect = node.getBoundingClientRect();
return {
type: "GetClientRect",
origin: [rect.x, rect.y],
size: [rect.width, rect.height],
};
}
setFocus(id: number, focus: boolean) {
const node = this.nodes[id];
if (!(node instanceof HTMLElement)) {
return false;
}
if (focus) {
node.focus();
} else {
node.blur();
}
return true;
}
LoadChild(array: number[]) {
// iterate through each number and get that child
let node = this.stack[this.stack.length - 1] as Node;
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: number, 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]);
}
}
}
function handler(event: Event, interpreter: NativeInterpreter, name: string, bubbles: boolean) {
const target = event.target!;
const realId = target_id(target)!;
let contents = serializeEvent(event);
if (target instanceof HTMLFormElement && (event.type === "submit" || event.type === "input")) {
const formData = new FormData(target);
for (let name of formData.keys()) {
const fieldType = target.elements[name].type;
switch (fieldType) {
case "select-multiple":
contents.values[name] = formData.getAll(name);
break;
// add cases for fieldTypes that can hold multiple values here
default:
contents.values[name] = formData.get(name);
break;
}
}
}
if (target instanceof HTMLSelectElement && (event.type === "input" || event.type === "change")) {
const selectData = target.options;
contents.values["options"] = [];
for (let i = 0; i < selectData.length; i++) {
let option = selectData[i];
if (option.selected) {
contents.values["options"].push(option.value.toString());
}
}
}
// If there's files to read
if (target instanceof HTMLInputElement && (event.type === "change" || event.type === "input")) {
if (target.getAttribute("type") === "file") {
read_files(target, contents, bubbles, realId, name);
return;
}
}
prevents_default(event, target);
// Handle the event on the virtualdom and then process whatever its output was
let body = {
name: name,
data: serializeEvent(event),
element: parseInt(realId),
bubbles,
};
if (waitForVirtualDomPreventDefault(JSON.stringify(body))) {
event.preventDefault();
}
}
export function waitForVirtualDomPreventDefault(contents: string): boolean {
// 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).preventDefault;
}
async function read_files(target: HTMLInputElement, contents, bubbles, realId, name) {
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 };
if (realId === null) {
return;
}
const message = window.interpreter.serializeIpcMessage("user_event", {
name: name,
element: parseInt(realId),
data: contents,
bubbles,
});
window.ipc.postMessage(message);
}
export function target_id(target: EventTarget): string | null {
if (!(target instanceof Element)) {
return null;
}
function find_real_id(target: Element): string | null {
let realId = target.getAttribute(`data-dioxus-id`);
// walk the tree to find the real element
while (realId == null) {
// we've reached the root we don't want to send an event
if (target.parentElement === null) {
return null;
}
target = target.parentElement;
if (target instanceof Element) {
realId = target.getAttribute(`data-dioxus-id`);
}
}
return realId;
}
return find_real_id(target);
}
// 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.preventFormNavigate(event, target);
}
}
preventFormNavigate(event: Event, target: Element) {
// 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) {
window.ipc.postMessage(
window.interpreter.serializeIpcMessage("browser_open", { href })
);
}
}
}
@@ -0,0 +1,83 @@
// The JS<->Rust bridge
// This file is thin, suitable just for the web
// If you want the more full-featured intrepreter, look at the native interpreter which extends this with additional functionality
//
// We're using sledgehammer directly
import { Interpreter, m } from "./interpreter_core";
export class WebInterpreter extends Interpreter {
constructor(root: Element, handler: EventListener) {
super(root, handler);
}
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 = m.getUint8(ptr);
for (node = node.firstChild; end > 0; end--) {
node = node.nextSibling;
}
}
return node;
}
saveTemplate(nodes: Element[], tmpl_id: string) {
this.templates[tmpl_id] = nodes;
}
hydrateRoot(ids: { [key: number]: number }) {
const hydrateNodes = document.querySelectorAll('[data-node-hydration]');
for (let i = 0; i < hydrateNodes.length; i++) {
const hydrateNode = hydrateNodes[i];
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 as Element;
}
currentNode = treeWalker.nextNode();
}
}
getNode(id: number): Element {
return this.nodes[id];
}
appendChildren(id: number, many: number) {
const root = this.nodes[id];
const els = this.stack.splice(this.stack.length - many);
for (let k = 0; k < many; k++) {
this.root.appendChild(els[k]);
}
}
}
+494
View File
@@ -0,0 +1,494 @@
// Handle serialization of the event data across the IPC boundarytype SerialziedEvent = {};
type SerializedEvent = {
values?: { [key: string]: FormDataEntryValue[] } | FormDataEntryValue[];
value?: string;
[key: string]: any;
};
function serializeEvent(event: Event): SerializedEvent {
// copy, cut, paste
if (event instanceof KeyboardEvent) {
return {
char_code: event.charCode,
is_composing: event.isComposing,
key: event.key,
alt_key: event.altKey,
ctrl_key: event.ctrlKey,
meta_key: event.metaKey,
key_code: event.keyCode,
shift_key: event.shiftKey,
location: event.location,
repeat: event.repeat,
which: event.which,
code: event.code,
};
}
// "pointerdown" "pointermove" "pointerup" "pointercancel" "gotpointercapture" "lostpointercapture" "pointerenter"
// "pointerleave" "pointerover" "pointerout"
if (event instanceof PointerEvent) {
return {
alt_key: event.altKey,
button: event.button,
buttons: event.buttons,
client_x: event.clientX,
client_y: event.clientY,
ctrl_key: event.ctrlKey,
meta_key: event.metaKey,
page_x: event.pageX,
page_y: event.pageY,
screen_x: event.screenX,
screen_y: event.screenY,
shift_key: event.shiftKey,
pointer_id: event.pointerId,
width: event.width,
height: event.height,
pressure: event.pressure,
tangential_pressure: event.tangentialPressure,
tilt_x: event.tiltX,
tilt_y: event.tiltY,
twist: event.twist,
pointer_type: event.pointerType,
is_primary: event.isPrimary,
};
}
if (event instanceof MouseEvent) {
return {
alt_key: event.altKey,
button: event.button,
buttons: event.buttons,
client_x: event.clientX,
client_y: event.clientY,
ctrl_key: event.ctrlKey,
meta_key: event.metaKey,
offset_x: event.offsetX,
offset_y: event.offsetY,
page_x: event.pageX,
page_y: event.pageY,
screen_x: event.screenX,
screen_y: event.screenY,
shift_key: event.shiftKey,
};
}
if (event instanceof TouchEvent) {
return {
alt_key: event.altKey,
ctrl_key: event.ctrlKey,
meta_key: event.metaKey,
shift_key: event.shiftKey,
changed_touches: event.changedTouches,
target_touches: event.targetTouches,
touches: event.touches,
};
}
if (event instanceof WheelEvent) {
return {
delta_x: event.deltaX,
delta_y: event.deltaY,
delta_z: event.deltaZ,
delta_mode: event.deltaMode,
};
}
if (event instanceof AnimationEvent) {
return {
animation_name: event.animationName,
elapsed_time: event.elapsedTime,
pseudo_element: event.pseudoElement,
};
}
if (event instanceof TransitionEvent) {
return {
property_name: event.propertyName,
elapsed_time: event.elapsedTime,
pseudo_element: event.pseudoElement,
};
}
if (event instanceof ClipboardEvent) {
return {};
}
if (event instanceof CompositionEvent) {
return {
data: event.data,
};
}
if (event instanceof DragEvent) {
return {
mouse: {
alt_key: event.altKey,
ctrl_key: event.ctrlKey,
meta_key: event.metaKey,
shift_key: event.shiftKey,
},
files: [],
};
}
if (event instanceof FocusEvent) {
return {};
}
return {};
}
function isElementNode(node: Node) {
return node.nodeType == 1;
}
function eventBubbles(eventName: string) {
switch (eventName) {
case "copy":
return true;
case "cut":
return true;
case "paste":
return true;
case "compositionend":
return true;
case "compositionstart":
return true;
case "compositionupdate":
return true;
case "keydown":
return true;
case "keypress":
return true;
case "keyup":
return true;
case "focus":
return false;
case "focusout":
return true;
case "focusin":
return true;
case "blur":
return false;
case "change":
return true;
case "input":
return true;
case "invalid":
return true;
case "reset":
return true;
case "submit":
return true;
case "click":
return true;
case "contextmenu":
return true;
case "doubleclick":
return true;
case "dblclick":
return true;
case "drag":
return true;
case "dragend":
return true;
case "dragenter":
return false;
case "dragexit":
return false;
case "dragleave":
return true;
case "dragover":
return true;
case "dragstart":
return true;
case "drop":
return true;
case "mousedown":
return true;
case "mouseenter":
return false;
case "mouseleave":
return false;
case "mousemove":
return true;
case "mouseout":
return true;
case "scroll":
return false;
case "mouseover":
return true;
case "mouseup":
return true;
case "pointerdown":
return true;
case "pointermove":
return true;
case "pointerup":
return true;
case "pointercancel":
return true;
case "gotpointercapture":
return true;
case "lostpointercapture":
return true;
case "pointerenter":
return false;
case "pointerleave":
return false;
case "pointerover":
return true;
case "pointerout":
return true;
case "select":
return true;
case "touchcancel":
return true;
case "touchend":
return true;
case "touchmove":
return true;
case "touchstart":
return true;
case "wheel":
return true;
case "abort":
return false;
case "canplay":
return false;
case "canplaythrough":
return false;
case "durationchange":
return false;
case "emptied":
return false;
case "encrypted":
return true;
case "ended":
return false;
case "error":
return false;
case "loadeddata":
case "loadedmetadata":
case "loadstart":
case "load":
return false;
case "pause":
return false;
case "play":
return false;
case "playing":
return false;
case "progress":
return false;
case "ratechange":
return false;
case "seeked":
return false;
case "seeking":
return false;
case "stalled":
return false;
case "suspend":
return false;
case "timeupdate":
return false;
case "volumechange":
return false;
case "waiting":
return false;
case "animationstart":
return true;
case "animationend":
return true;
case "animationiteration":
return true;
case "transitionend":
return true;
case "toggle":
return true;
case "mounted":
return false;
}
return true;
}
// switch (event.type) {
// case "copy": case "cut": case "paste": {
// return {};
// }
// case "compositionend":
// case "compositionstart":
// case "compositionupdate": {
// let { data } = event;
// return {
// data,
// };
// }
// case "keydown": case "keypress": case "keyup": {
// if (event instanceof KeyboardEvent) {
// return {
// char_code: event.charCode,
// is_composing: event.isComposing,
// key: event.key,
// alt_key: event.altKey,
// ctrl_key: event.ctrlKey,
// meta_key: event.metaKey,
// key_code: event.keyCode,
// shift_key: event.shiftKey,
// location: event.location,
// repeat: event.repeat,
// which: event.which,
// code: event.code,
// };
// };
// }
// case "focus":
// case "blur": {
// return {};
// }
// case "change": {
// let target = event.target;
// let value;
// if (target.type === "checkbox" || target.type === "radio") {
// value = target.checked ? "true" : "false";
// } else {
// value = target.value ?? target.textContent;
// }
// return {
// value: value,
// values: {},
// };
// }
// case "input":
// case "invalid":
// case "reset":
// case "submit": {
// let target = event.target;
// let value = target.value ?? target.textContent;
// if (target.type === "checkbox") {
// value = target.checked ? "true" : "false";
// }
// return {
// value: value,
// values: {},
// };
// }
// case "drag":
// case "dragend":
// case "dragenter":
// case "dragexit":
// case "dragleave":
// case "dragover":
// case "dragstart":
// case "drop": {
// let files = [];
// if (event.dataTransfer && event.dataTransfer.files) {
// files = ["a", "b", "c"];
// // files = await serializeFileList(event.dataTransfer.files);
// }
// return { mouse: get_mouse_data(event), files };
// }
// case "click":
// case "contextmenu":
// case "doubleclick":
// case "dblclick":
// case "mousedown":
// case "mouseenter":
// case "mouseleave":
// case "mousemove":
// case "mouseout":
// case "mouseover":
// case "mouseup": {
// return get_mouse_data(event);
// }
// case "select": {
// return {};
// }
// case "touchcancel":
// case "touchend":
// case "touchmove":
// case "touchstart": {
// const { altKey, ctrlKey, metaKey, shiftKey } = event;
// return {
// // changed_touches: event.changedTouches,
// // target_touches: event.targetTouches,
// // touches: event.touches,
// alt_key: altKey,
// ctrl_key: ctrlKey,
// meta_key: metaKey,
// shift_key: shiftKey,
// };
// }
// case "scroll": {
// return {};
// }
// case "wheel": {
// const { deltaX, deltaY, deltaZ, deltaMode } = event;
// return {
// delta_x: deltaX,
// delta_y: deltaY,
// delta_z: deltaZ,
// delta_mode: deltaMode,
// };
// }
// case "animationstart":
// case "animationend":
// case "animationiteration": {
// const { animationName, elapsedTime, pseudoElement } = event;
// return {
// animation_name: animationName,
// elapsed_time: elapsedTime,
// pseudo_element: pseudoElement,
// };
// }
// case "transitionend": {
// const { propertyName, elapsedTime, pseudoElement } = event;
// return {
// property_name: propertyName,
// elapsed_time: elapsedTime,
// pseudo_element: pseudoElement,
// };
// }
// case "abort":
// case "canplay":
// case "canplaythrough":
// case "durationchange":
// case "emptied":
// case "encrypted":
// case "ended":
// case "error":
// case "loadeddata":
// case "loadedmetadata":
// case "loadstart":
// case "pause":
// case "play":
// case "playing":
// case "progress":
// case "ratechange":
// case "seeked":
// case "seeking":
// case "stalled":
// case "suspend":
// case "timeupdate":
// case "volumechange":
// case "waiting": {
// return {};
// }
// case "toggle": {
// return {};
// }
// default: {
// return {};
// }
// }
@@ -0,0 +1,105 @@
// this function should try and stay fast, if possible
export function setAttributeInner(node: HTMLElement, field: string, value: string, ns: string) {
// we support a single namespace by default: style
if (ns === "style") {
node.style.setProperty(field, value);
return;
}
// If there's a namespace, use setAttributeNS (svg, mathml, etc.)
if (!!ns) {
node.setAttributeNS(ns, field, value);
return
}
// A few attributes are need to be set with either boolean values or require some sort of translation
switch (field) {
case "value":
if (value !== node.getAttribute("value")) {
node.setAttribute(field, value);
}
break;
case "initial_value":
node.setAttribute("defaultValue", value);
break;
case "checked":
if (node instanceof HTMLInputElement) {
node.checked = truthy(value);
}
break;
case "initial_checked":
if (node instanceof HTMLInputElement) {
node.defaultChecked = truthy(value);
}
break;
case "selected":
if (node instanceof HTMLOptionElement) {
node.selected = truthy(value);
}
break;
case "initial_selected":
if (node instanceof HTMLOptionElement) {
node.defaultSelected = truthy(value);
}
break;
case "dangerous_inner_html":
node.innerHTML = value;
break;
// The presence of a an attribute is enough to set it to true, provided the value is being set to a truthy value
// Again, kinda ugly and would prefer this logic to be baked into dioxus-html at compiile time
default:
// https://github.com/facebook/react/blob/8b88ac2592c5f555f315f9440cbb665dd1e7457a/packages/react-dom/src/shared/DOMProperty.js#L352-L364
if (!truthy(value) && isBoolAttr(field)) {
node.removeAttribute(field);
} else {
node.setAttribute(field, value);
}
}
}
function truthy(val: string | boolean) {
return val === "true" || val === true;
}
function isBoolAttr(field: string): boolean {
switch (field) {
case "allowfullscreen":
case "allowpaymentrequest":
case "async":
case "autofocus":
case "autoplay":
case "checked":
case "controls":
case "default":
case "defer":
case "disabled":
case "formnovalidate":
case "hidden":
case "ismap":
case "itemscope":
case "loop":
case "multiple":
case "muted":
case "nomodule":
case "novalidate":
case "open":
case "playsinline":
case "readonly":
case "required":
case "reversed":
case "selected":
case "truespeed":
case "webkitdirectory":
return true;
default:
return false;
}
}