native.ts 13 KB

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