native.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. // This file provides an extended variant of the interpreter used for desktop and liveview interaction
  2. //
  3. // This file lives on the renderer, not the host. It's basically a polyfill over functionality that the host can't
  4. // provide since it doesn't have access to the dom.
  5. import { BaseInterpreter, NodeId } from "./core";
  6. import { SerializedEvent, serializeEvent } from "./serialize";
  7. // okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope
  8. // we want to extend it, and it technically extends base intepreter. To make typescript happy,
  9. // we're going to bind the JSChannel_ object to the JSChannel object, and then extend it
  10. var JSChannel_: typeof BaseInterpreter;
  11. // @ts-ignore - this is coming from the host
  12. if (RawInterpreter !== undefined && RawInterpreter !== null) {
  13. // @ts-ignore - this is coming from the host
  14. JSChannel_ = RawInterpreter;
  15. };
  16. export class NativeInterpreter extends JSChannel_ {
  17. intercept_link_redirects: boolean;
  18. ipc: any;
  19. editsPath: string;
  20. kickStylesheets: boolean;
  21. // eventually we want to remove liveview and build it into the server-side-events of fullstack
  22. // however, for now we need to support it since SSE in fullstack doesn't exist yet
  23. liveview: boolean;
  24. constructor(editsPath: string) {
  25. super();
  26. this.editsPath = editsPath;
  27. this.kickStylesheets = false;
  28. }
  29. initialize(root: HTMLElement): void {
  30. this.intercept_link_redirects = true;
  31. this.liveview = false;
  32. // attach an event listener on the body that prevents file drops from navigating
  33. // this is because the browser will try to navigate to the file if it's dropped on the window
  34. window.addEventListener("dragover", function (e) {
  35. // // check which element is our target
  36. if (e.target instanceof Element && e.target.tagName != "INPUT") {
  37. e.preventDefault();
  38. }
  39. }, false);
  40. window.addEventListener("drop", function (e) {
  41. let target = e.target;
  42. if (!(target instanceof Element)) {
  43. return;
  44. }
  45. // Dropping a file on the window will navigate to the file, which we don't want
  46. e.preventDefault();
  47. }, false);
  48. // attach a listener to the route that listens for clicks and prevents the default file dialog
  49. window.addEventListener("click", (event) => {
  50. const target = event.target;
  51. if (target instanceof HTMLInputElement && target.getAttribute("type") === "file") {
  52. // 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
  53. let target_id = getTargetId(target);
  54. if (target_id !== null) {
  55. const message = this.serializeIpcMessage("file_dialog", {
  56. event: "change&input",
  57. accept: target.getAttribute("accept"),
  58. directory: target.getAttribute("webkitdirectory") === "true",
  59. multiple: target.hasAttribute("multiple"),
  60. target: target_id,
  61. bubbles: event.bubbles,
  62. });
  63. this.ipc.postMessage(message);
  64. event.preventDefault();
  65. }
  66. }
  67. });
  68. // @ts-ignore - wry gives us this
  69. this.ipc = window.ipc;
  70. // make sure we pass the handler to the base interpreter
  71. const handler: EventListener = (event) => this.handleEvent(event, event.type, true);
  72. super.initialize(root, handler);
  73. }
  74. serializeIpcMessage(method: string, params = {}) {
  75. return JSON.stringify({ method, params });
  76. }
  77. scrollTo(id: NodeId, behavior: ScrollBehavior) {
  78. const node = this.nodes[id];
  79. if (node instanceof HTMLElement) {
  80. node.scrollIntoView({ behavior });
  81. }
  82. }
  83. getClientRect(id: NodeId): { type: string; origin: number[]; size: number[]; } | undefined {
  84. const node = this.nodes[id];
  85. if (node instanceof HTMLElement) {
  86. const rect = node.getBoundingClientRect();
  87. return {
  88. type: "GetClientRect",
  89. origin: [rect.x, rect.y],
  90. size: [rect.width, rect.height],
  91. };
  92. }
  93. }
  94. setFocus(id: NodeId, focus: boolean) {
  95. const node = this.nodes[id];
  96. if (node instanceof HTMLElement) {
  97. if (focus) {
  98. node.focus();
  99. } else {
  100. node.blur();
  101. }
  102. }
  103. }
  104. // ignore the fact the base interpreter uses ptr + len but we use array...
  105. // @ts-ignore
  106. loadChild(array: number[]) {
  107. // iterate through each number and get that child
  108. let node = this.stack[this.stack.length - 1];
  109. for (let i = 0; i < array.length; i++) {
  110. let end = array[i];
  111. for (node = node.firstChild; end > 0; end--) {
  112. node = node.nextSibling;
  113. }
  114. }
  115. return node;
  116. }
  117. appendChildren(id: NodeId, many: number) {
  118. const root = this.nodes[id];
  119. const els = this.stack.splice(this.stack.length - many);
  120. for (let k = 0; k < many; k++) {
  121. root.appendChild(els[k]);
  122. }
  123. }
  124. handleEvent(event: Event, name: string, bubbles: boolean) {
  125. const target = event.target!;
  126. const realId = getTargetId(target)!;
  127. const contents = serializeEvent(event, target);
  128. // Handle the event on the virtualdom and then preventDefault if it also preventsDefault
  129. // Some listeners
  130. let body = {
  131. name: name,
  132. data: contents,
  133. element: realId,
  134. bubbles,
  135. };
  136. // Run any prevent defaults the user might've set
  137. // This is to support the prevent_default: "onclick" attribute that dioxus has had for a while, but is not necessary
  138. // now that we expose preventDefault to the virtualdom on desktop
  139. // Liveview will still need to use this
  140. this.preventDefaults(event, target);
  141. // liveview does not have syncronous event handling, so we need to send the event to the host
  142. if (this.liveview) {
  143. // Okay, so the user might've requested some files to be read
  144. if (target instanceof HTMLInputElement && (event.type === "change" || event.type === "input")) {
  145. if (target.getAttribute("type") === "file") {
  146. this.readFiles(target, contents, bubbles, realId, name);
  147. }
  148. }
  149. } else {
  150. const message = this.serializeIpcMessage("user_event", body);
  151. this.ipc.postMessage(message);
  152. // // Run the event handler on the virtualdom
  153. // // capture/prevent default of the event if the virtualdom wants to
  154. // const res = handleVirtualdomEventSync(JSON.stringify(body));
  155. // if (res.preventDefault) {
  156. // event.preventDefault();
  157. // }
  158. // if (res.stopPropagation) {
  159. // event.stopPropagation();
  160. // }
  161. }
  162. }
  163. // This should:
  164. // - prevent form submissions from navigating
  165. // - prevent anchor tags from navigating
  166. // - prevent buttons from submitting forms
  167. // - let the virtualdom attempt to prevent the event
  168. preventDefaults(event: Event, target: EventTarget) {
  169. let preventDefaultRequests: string | null = null;
  170. // Some events can be triggered on text nodes, which don't have attributes
  171. if (target instanceof Element) {
  172. preventDefaultRequests = target.getAttribute(`dioxus-prevent-default`);
  173. }
  174. if (preventDefaultRequests && preventDefaultRequests.includes(`on${event.type}`)) {
  175. event.preventDefault();
  176. }
  177. if (event.type === "submit") {
  178. event.preventDefault();
  179. }
  180. // Attempt to intercept if the event is a click
  181. if (target instanceof Element && event.type === "click") {
  182. this.handleClickNavigate(event, target, preventDefaultRequests);
  183. }
  184. }
  185. handleClickNavigate(event: Event, target: Element, preventDefaultRequests: string) {
  186. // todo call prevent default if it's the right type of event
  187. if (!this.intercept_link_redirects) {
  188. return;
  189. }
  190. // prevent buttons in forms from submitting the form
  191. if (target.tagName === "BUTTON" && event.type == "submit") {
  192. event.preventDefault();
  193. }
  194. // If the target is an anchor tag, we want to intercept the click too, to prevent the browser from navigating
  195. let a_element = target.closest("a");
  196. if (a_element == null) {
  197. return;
  198. }
  199. event.preventDefault();
  200. let elementShouldPreventDefault =
  201. preventDefaultRequests && preventDefaultRequests.includes(`onclick`);
  202. let aElementShouldPreventDefault = a_element.getAttribute(
  203. `dioxus-prevent-default`
  204. );
  205. let linkShouldPreventDefault =
  206. aElementShouldPreventDefault &&
  207. aElementShouldPreventDefault.includes(`onclick`);
  208. if (!elementShouldPreventDefault && !linkShouldPreventDefault) {
  209. const href = a_element.getAttribute("href");
  210. if (href !== "" && href !== null && href !== undefined) {
  211. this.ipc.postMessage(
  212. this.serializeIpcMessage("browser_open", { href })
  213. );
  214. }
  215. }
  216. }
  217. waitForRequest(headless: boolean) {
  218. fetch(new Request(this.editsPath))
  219. .then(response => response.arrayBuffer())
  220. .then(bytes => {
  221. // In headless mode, the requestAnimationFrame callback is never called, so we need to run the bytes directly
  222. if (headless) {
  223. // @ts-ignore
  224. this.run_from_bytes(bytes);
  225. } else {
  226. requestAnimationFrame(() => {
  227. // @ts-ignore
  228. this.run_from_bytes(bytes)
  229. });
  230. }
  231. this.waitForRequest(headless);
  232. });
  233. }
  234. kickAllStylesheetsOnPage() {
  235. // If this function is being called and we have not explicitly set kickStylesheets to true, then we should
  236. // force kick the stylesheets, regardless if they have a dioxus attribute or not
  237. // This happens when any hotreload happens.
  238. let stylesheets = document.querySelectorAll("link[rel=stylesheet]");
  239. for (let i = 0; i < stylesheets.length; i++) {
  240. let sheet = stylesheets[i] as HTMLLinkElement;
  241. // Using `cache: reload` will force the browser to re-fetch the stylesheet and bust the cache
  242. fetch(sheet.href, { cache: "reload" }).then(() => {
  243. sheet.href = sheet.href + "?" + Math.random();
  244. });
  245. }
  246. }
  247. // A liveview only function
  248. // Desktop will intercept the event before it hits this
  249. async readFiles(target: HTMLInputElement, contents: SerializedEvent, bubbles: boolean, realId: NodeId, name: string) {
  250. let files = target.files!;
  251. let file_contents: { [name: string]: number[] } = {};
  252. for (let i = 0; i < files.length; i++) {
  253. const file = files[i];
  254. file_contents[file.name] = Array.from(
  255. new Uint8Array(await file.arrayBuffer())
  256. );
  257. }
  258. contents.files = { files: file_contents };
  259. const message = this.serializeIpcMessage("user_event", {
  260. name: name,
  261. element: realId,
  262. data: contents,
  263. bubbles,
  264. });
  265. this.ipc.postMessage(message);
  266. }
  267. }
  268. type EventSyncResult = {
  269. preventDefault: boolean;
  270. stopPropagation: boolean;
  271. stopImmediatePropagation: boolean;
  272. filesRequested: boolean;
  273. };
  274. // This function sends the event to the virtualdom and then waits for the virtualdom to process it
  275. //
  276. // However, it's not really suitable for liveview, because it's synchronous and will block the main thread
  277. // We should definitely consider using a websocket if we want to block... or just not block on liveview
  278. // Liveview is a little bit of a tricky beast
  279. function handleVirtualdomEventSync(contents: string): EventSyncResult {
  280. // Handle the event on the virtualdom and then process whatever its output was
  281. const xhr = new XMLHttpRequest();
  282. // Serialize the event and send it to the custom protocol in the Rust side of things
  283. xhr.timeout = 1000;
  284. xhr.open("GET", "/handle/event.please", false);
  285. xhr.setRequestHeader("Content-Type", "application/json");
  286. xhr.send(contents);
  287. // Deserialize the response, and then prevent the default/capture the event if the virtualdom wants to
  288. return JSON.parse(xhr.responseText);
  289. }
  290. function getTargetId(target: EventTarget): NodeId | null {
  291. // Ensure that the target is a node, sometimes it's nota
  292. if (!(target instanceof Node)) {
  293. return null;
  294. }
  295. let ourTarget = target;
  296. let realId = null;
  297. while (realId == null) {
  298. if (ourTarget === null) {
  299. return null;
  300. }
  301. if (ourTarget instanceof Element) {
  302. realId = ourTarget.getAttribute(`data-dioxus-id`);
  303. }
  304. ourTarget = ourTarget.parentNode;
  305. }
  306. return parseInt(realId);
  307. }
  308. // function applyFileUpload() {
  309. // let inputs = document.querySelectorAll("input");
  310. // for (let input of inputs) {
  311. // if (!input.getAttribute("data-dioxus-file-listener")) {
  312. // // prevent file inputs from opening the file dialog on click
  313. // const type = input.getAttribute("type");
  314. // if (type === "file") {
  315. // input.setAttribute("data-dioxus-file-listener", true);
  316. // input.addEventListener("click", (event) => {
  317. // let target = event.target;
  318. // let target_id = find_real_id(target);
  319. // if (target_id !== null) {
  320. // const send = (event_name) => {
  321. // 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 });
  322. // window.ipc.postMessage(message);
  323. // };
  324. // send("change&input");
  325. // }
  326. // event.preventDefault();
  327. // });
  328. // }
  329. // }
  330. // }