native.ts 14 KB

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