Repository: excalidraw/excalidraw-blog Branch: master Commit: 083a4277a924 Files: 43 Total size: 175.7 KB Directory structure: gitextract_g20e0di6/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── test.yml ├── .gitignore ├── .prettierignore ├── .vscode/ │ └── settings.json ├── .yarnrc ├── LICENSE ├── README.md ├── content/ │ └── blog/ │ ├── browser-fs-access/ │ │ └── index.md │ ├── building-excalidraw-p2p-collaboration-feature/ │ │ └── index.md │ ├── deprecating-excalidraw-electron/ │ │ └── index.md │ ├── enabling-translations/ │ │ └── index.md │ ├── end-to-end-encryption/ │ │ └── index.md │ ├── excalidraw-and-fugu/ │ │ └── index.md │ ├── introducing-excalidraw-plus/ │ │ └── index.md │ ├── one-year-of-excalidraw/ │ │ └── index.md │ ├── open-colors/ │ │ └── index.md │ ├── redesigning-editor-api/ │ │ └── index.md │ ├── reflections-on-excalidraw/ │ │ └── index.md │ ├── rethinking-virtual-whiteboard/ │ │ └── index.md │ ├── tell-your-story-with-charts/ │ │ └── index.md │ ├── webex-meetings-integration/ │ │ └── index.md │ ├── year-three/ │ │ └── index.md │ └── year-two/ │ └── index.md ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── package.json ├── src/ │ ├── blog.css │ ├── code.css │ ├── components/ │ │ ├── Toggle.css │ │ ├── Toggle.js │ │ ├── excalidraw.js │ │ ├── layout.js │ │ ├── layoutStyles.css │ │ └── seo.js │ ├── pages/ │ │ ├── 404.js │ │ └── index.js │ ├── styles.css │ ├── templates/ │ │ └── blog-post.js │ └── utils/ │ └── typography.js ├── static/ │ └── robots.txt └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: weekly day: sunday time: "01:00" ================================================ FILE: .github/workflows/test.yml ================================================ name: Test formatting on: push: branches: - master pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: Install and test run: | yarn yarn test ================================================ FILE: .gitignore ================================================ .cache .DS_Store .yarn-integrity *.log node_modules npm-debug.log* package-lock.json public yarn-debug.log* yarn-error.log yarn-error.log* ================================================ FILE: .prettierignore ================================================ .cache public ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.enabled": true } ================================================ FILE: .yarnrc ================================================ --add.exact true ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 Excalidraw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Excalidraw Blog > For news and updates visit: https://blog.excalidraw.com. ## Develop We are using [Gatsby](https://www.gatsbyjs.com/) and in order to run it locally, execute the following from the root: ``` yarn yarn start ``` Visit [`localhost:8000`](http://localhost:8000) to test it. ## Writing a Blog Post - Create a new folder inside the `content/blog/` - The folder name should follow `kebab-case` - Use the slug of the title of the post to name it - Create `index.md` inside that folder - Create `og.png` inside that folder for the open graph image - Add a frontmatter - `title`: Use Title Case for Titles - `date`: Date in ISO format. Example: `2020-03-12` - `note`: Optional. Displayed next to the date when reading a post. - `image`: Filename of the open graph image. Example: `og.png` - The `note` field is usually used to link to the original post when reposting ([example](https://blog.excalidraw.com/reflections-on-excalidraw/)) - Add somewhere the `` to declare your `excerpt` (it's used on the front page) ================================================ FILE: content/blog/browser-fs-access/index.md ================================================ --- title: Reading and writing files and directories with the browser-fs-access library date: 2020-12-09 note: 'This post appeared first on web.dev.' author: tomayac link: https://twitter.com/tomayac image: chrome-save-as.png --- Browsers have been able to deal with files and directories for a long time. The [File API](https://w3c.github.io/FileAPI/) provides features for representing file objects in web applications, as well as programmatically selecting them and accessing their data. The moment you look closer, though, all that glitters is not gold. ## The traditional way of dealing with files
If you know how it used to work the old way, you can jump down straight to the new way.
### Opening files As a developer, you can open and read files via the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file) element. In its simplest form, opening a file can look something like the code sample below. The `input` object gives you a [`FileList`](https://developer.mozilla.org/en-US/docs/Web/API/FileList), which in the case below consists of just one [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). A `File` is a specific kind of [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob), and can be used in any context that a Blob can. ```js const openFile = async () => { return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.addEventListener("change", () => { resolve(input.files[0]); }); input.click(); }); }; ``` ### Opening directories For opening folders (or directories), you can set the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-webkitdirectory) attribute. Apart from that, everything else works the same as above. Despite its vendor-prefixed name, `webkitdirectory` is not only usable in Chromium and WebKit browsers, but also in the legacy EdgeHTML-based Edge as well as in Firefox. ### Saving (rather: downloading) files For saving a file, traditionally, you are limited to _downloading_ a file, which works thanks to the [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download:~:text=download) attribute. Given a Blob, you can set the anchor's `href` attribute to a `blob:` URL that you can get from the [`URL.createObjectURL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL) method.
To prevent memory leaks, always revoke the URL after the download.
```js const saveFile = async (blob) => { const a = document.createElement("a"); a.download = "my-file.txt"; a.href = URL.createObjectURL(blob); a.addEventListener("click", (e) => { setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000); }); a.click(); }; ``` ### The problem A massive downside of the _download_ approach is that there is no way to make a classic open→edit→save flow happen, that is, there is no way to _overwrite_ the original file. Instead, you end up with a new _copy_ of the original file in the operating system's default Downloads folder whenever you "save".

The File System Access API

The File System Access API makes both operations, opening and saving, a lot simpler. It also enables _true saving_, that is, you can not only choose where to save a file, but also overwrite an existing file.
For a more thorough introduction to the File System Access API, see the article The File System Access API: simplifying access to local files.
### Opening files With the [File System Access API](https://wicg.github.io/file-system-access/), opening a file is a matter of one call to the `window.showOpenFilePicker()` method. This call returns a file handle, from which you can get the actual `File` via the `getFile()` method. ```js const openFile = async () => { try { // Always returns an array. const [handle] = await window.showOpenFilePicker(); return handle.getFile(); } catch (err) { console.error(err.name, err.message); } }; ``` ### Opening directories Open a directory by calling `window.showDirectoryPicker()` that makes directories selectable in the file dialog box. ### Saving files Saving files is similarly straightforward. From a file handle, you create a writable stream via `createWritable()`, then you write the Blob data by calling the stream's `write()` method, and finally you close the stream by calling its `close()` method. ```js const saveFile = async (blob) => { try { const handle = await window.showSaveFilePicker({ types: [ { accept: { // Omitted }, }, ], }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); return handle; } catch (err) { console.error(err.name, err.message); } }; ``` ## Introducing browser-fs-access As perfectly fine as the File System Access API is, it's [not yet widely available](https://caniuse.com/native-filesystem-api).
Browser support table for the File System Access API. All browsers are marked as 'no support' or 'behind a flag'.
Browser support table for the File System Access API. (Source)
This is why I see the File System Access API as a [progressive enhancement](https://web.dev/progressively-enhance-your-pwa). As such, I want to use it when the browser supports it, and use the traditional approach if not; all while never punishing the user with unnecessary downloads of unsupported JavaScript code. The [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) library is my answer to this challenge. ### Design philosophy Since the File System Access API is still likely to change in the future, the browser-fs-access API is not modeled after it. That is, the library is not a [polyfill](https://developer.mozilla.org/en-US/docs/Glossary/Polyfill), but rather a [ponyfill](https://github.com/sindresorhus/ponyfill). You can (statically or dynamically) exclusively import whatever functionality you need to keep your app as small as possible. The available methods are the aptly named [`fileOpen()`](https://github.com/GoogleChromeLabs/browser-fs-access#opening-files), [`directoryOpen()`](https://github.com/GoogleChromeLabs/browser-fs-access#opening-directories), and [`fileSave()`](https://github.com/GoogleChromeLabs/browser-fs-access#saving-files). Internally, the library feature-detects if the File System Access API is supported, and then imports the corresponding code path. ### Using the browser-fs-access library The three methods are intuitive to use. You can specify your app's accepted `mimeTypes` or file `extensions`, and set a `multiple` flag to allow or disallow selection of multiple files or directories. For full details, see the [browser-fs-access API documentation](https://github.com/GoogleChromeLabs/browser-fs-access#api-documentation). The code sample below shows how you can open and save image files. ```js // The imported methods will use the File // System Access API or a fallback implementation. import { fileOpen, directoryOpen, fileSave, } from "https://unpkg.com/browser-fs-access"; (async () => { // Open an image file. const blob = await fileOpen({ mimeTypes: ["image/*"], }); // Open multiple image files. const blobs = await fileOpen({ mimeTypes: ["image/*"], multiple: true, }); // Open all files in a directory, // recursively including subdirectories. const blobsInDirectory = await directoryOpen({ recursive: true, }); // Save a file. await fileSave(blob, { fileName: "Untitled.png", }); })(); ``` ### Demo You can see the above code in action in a [demo](https://browser-fs-access.glitch.me/) on Glitch. Its [source code](https://glitch.com/edit/#!/browser-fs-access) is likewise available there. Since for security reasons cross origin sub frames are not allowed to show a file picker, the demo cannot be embedded in this article. ## The browser-fs-access library in the wild In my free time, I contribute a tiny bit to an [installable PWA](https://web.dev/progressive-web-apps/#installable) called [Excalidraw](https://excalidraw.com/), a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel. It is fully responsive and works well on a range of devices from small mobile phones to computers with large screens. This means it needs to deal with files on all the various platforms whether or not they support the File System Access API. This makes it a great candidate for the browser-fs-access library. I can, for example, start a drawing on my iPhone, save it (technically: download it, since Safari does not support the File System Access API) to my iPhone Downloads folder, open the file on my desktop (after transferring it from my phone), modify the file, and overwrite it with my changes, or even save it as a new file.
An Excalidraw drawing on an iPhone.
Starting an Excalidraw drawing on an iPhone where the File System Access API is not supported, but where a file can be saved (downloaded) to the Downloads folder.
The modified Excalidraw drawing on Chrome on the desktop.
Opening and modifying the Excalidraw drawing on the desktop where the File System Access API is supported and thus the file can be accessed via the API.
Overwriting the original file with the modifications.
Overwriting the original file with the modifications to the original Excalidraw drawing file. The browser shows a dialog asking me whether this is fine.
Saving the modifications to a new Excalidraw drawing file.
Saving the modifications to a new Excalidraw file. The original file remains untouched.
### Real life code sample Below, you can see an actual example of browser-fs-access as it is used in Excalidraw. This excerpt is taken from [`/src/data/json.ts`](https://github.com/excalidraw/excalidraw/blob/cd87bd6901b47430a692a06a8928b0f732d77097/src/data/json.ts#L24-L52). Of special interest is how the `saveAsJSON()` method passes either a file handle or `null` to browser-fs-access' `fileSave()` method, which causes it to overwrite when a handle is given, or to save to a new file if not. ```js export const saveAsJSON = async ( elements: readonly ExcalidrawElement[], appState: AppState, fileHandle: any, ) => { const serialized = serializeAsJSON(elements, appState); const blob = new Blob([serialized], { type: "application/json", }); const name = `${appState.name}.excalidraw`; (window as any).handle = await fileSave( blob, { fileName: name, description: "Excalidraw file", extensions: ["excalidraw"], }, fileHandle || null, ); }; export const loadFromJSON = async () => { const blob = await fileOpen({ description: "Excalidraw files", extensions: ["json", "excalidraw"], mimeTypes: ["application/json"], }); return loadFromBlob(blob); }; ``` ### UI considerations Whether in Excalidraw or your app, the UI should adapt to the browser's support situation. If the File System Access API is supported (`if ('showOpenFilePicker' in window) {}`) you can show a **Save As** button in addition to a **Save** button. The screenshots below show the difference between Excalidraw's responsive main app toolbar on iPhone and on Chrome desktop. Note how on iPhone the **Save As** button is missing.
Excalidraw app toolbar on iPhone with just a 'Save' button.
Excalidraw app toolbar on iPhone with just a Save button.
Excalidraw app toolbar on Chrome desktop with a 'Save' and a 'Save As' button.
Excalidraw app toolbar on Chrome with a Save and a focused Save As button.
## Conclusions Working with native files technically works on all modern browsers. On browsers that support the File System Access API, you can make the experience better by allowing for true saving and overwriting (not just downloading) of files and by letting your users create new files wherever they want, all while remaining functional on browsers that do not support the File System Access API. The [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) makes your life easier by dealing with the subtleties of progressive enhancement and making your code as simple as possible. ## Acknowledgements This article was reviewed by [Joe Medley](https://github.com/jpmedley) and [Kayce Basques](https://github.com/kaycebasques). Thanks to the [contributors to Excalidraw](https://github.com/excalidraw/excalidraw/graphs/contributors) for their work on the project and for reviewing my Pull Requests. ================================================ FILE: content/blog/building-excalidraw-p2p-collaboration-feature/index.md ================================================ --- title: Building Excalidraw's P2P Collaboration Feature date: 2020-03-29 author: idlewinn link: https://twitter.com/edwinlin1987 image: og.png --- [Excalidraw](https://excalidraw.com/) is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them. As tech companies started to institute mandatory work from home policies due to the [COVID-19 pandemic](https://en.wikipedia.org/wiki/2019%E2%80%9320_coronavirus_pandemic), we realized that Excalidraw could be the perfect substitute for the whiteboard that is usually required for systems design interviews. The problem was that Excalidraw was built to be a fundamentally single-player experience. However, we ([@idlewinn](https://twitter.com/edwinlin1987) and [@floydophone](https://twitter.com/floydophone)) were able to quickly add basic multiplayer support over a long weekend, and are continuing to make improvements to it every day. ## The requirements - We wanted to be able to use this in our day jobs. In order to do that, it had to support [end-to-end encryption](/end-to-end-encryption/). - We did not want to store anything server-side. Therefore, we opted for a pseudo-P2P model, where a central server relays end-to-end encrypted messages to all the peers in the room, but does no centralized coordination. ## Excalidraw's single-player architecture Originally, Excalidraw kept an array of all the different drawn shapes -- called `ExcalidrawElement`s -- in Z-index order. The `ExcalidrawElement` interface looked something like this: ```typescript interface ExcalidrawElement { id: string; type: "square" | "circle" | "arrow"; width: number; height: number; // ... other fields describing the shape ... canvas: HTMLCanvasElement; isSelected: boolean; } ``` This architecture was very easy to use client-side, but as we'll see, presented some challenges as we moved to multiplayer. ## Moving to multiplayer We actually had two parallel multiplayer implementations. One was built on top of [Firebase](https://firebase.com/), the other on top of [Socket.io](https://socket.io/). We ended up going with Socket.io, but both implementations developed the same set of bugs, and the mitigations we applied in one branch were easily ported to the other. Conceptually, we want to share the array of `ExcalidrawElement`s amongst multiple players. The first step was to refactor `ExcalidrawElement` to remove any state specific to a single player. This meant pulling out `canvas` and `isSelected` properties and storing them in a different data structure, as an `HTMLCanvasElement` can't be shared over the network, and every player will have a different selection state. Once we had these properties moved out, we started sharing the `ExcalidrawElement` array between peers. ## Socket.IO When we started investigating multiplayer, we knew that WebSockets would be an important part of the this project because they enable rapid message based communication between clients without polling the server - in order to provide a smooth user experience we would need to sync very frequently. Socket.IO was a great candidate for this project because it's a popular open source library with a lot of support, it has many features relevant to our use case right out of the box, and it has some level of support even for browsers that don't have support for the [WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API). Some features of Socket.IO that are particularly beneficial to multiplayer: - **Auto-reconnection:** if you get disconnected for any reason, you don't need to reload the page to see updates. - **Binary Support:** our encrypted messages can be passed between the client and the server back to other clients as `ArrayBuffer`, so we don't need to convert them to other data structures at any point in the transit. - **Room Support:** we want to limit updates only to the clients that care about them, and having a separate room per collaborative session was a straightforward way to implement that. ## Dealing with conflicts: adding new elements Once we started sharing state between peers, we immediately ran into issues. The first was when elements were added. In single-player Excalidraw, we would just append the element to the end of the array. In multiplayer Excalidraw, there was a race condition. If _peer A_ is adding a new element while _peer B_ is changing the color of an existing element, _peer C_ will either lose _peer A_'s new element or _peer B_'s edits. Even worse, if _peer A_ receives _peer B_'s updates while they are editing, the editor would delete the element from right under their mouse! This was clearly a bad user experience and we had to fix it before sharing this with anyone. https://excalidraw.com/#json=5068269564198912,PmL8fegqNyHb0fKxoG6YAA To fix this, we adopted an architecture of _merging states_ when we receive them. When _peer A_ receives an update from _peer B_, it looks at all the `ExcalidrawElement.id`s it has locally, and all of the incoming `ExcalidrawElement.id`s, and creates a new `ExcalidrawElement` array containing the union of both the local and incoming set. ## Dealing with conflicts: deleting elements There's a big problem with the model above. If we're always keeping the union of all of the elements, there will be no way to delete any shapes from the canvas, as other peers would immediately resync the deleted shape and it would be merged back into the array. To solve this problem, we used a method called [tombstoning](). We added a new field to the `ExcalidrawElement` interface called `isDeleted`. Peers would set this field to `true` to delete an element, and would filter the deleted elements out of the array at runtime. https://excalidraw.com/#json=5148123005452288,FnrZbAe4qkHQCSd2BSkUIQ The problem with tombstoning is that the size of your list grows forever, as nothing is ever removed from the array. We don't consider this a problem as a shared whiteboard session will only contain a few dozen shapes, but we do remove any element with `isDeleted` set to true when saving to persistent storage, so long-lived drawings where this may become a problem are cleaned up. ## Dealing with conflicts: merging concurrent edits There is still a problem with the model. Concurrent edits will still be lost, as our merge algorithm only looks at new elements being added or removed, and not changes to existing elements themselves. To fix this, we introduced a new field to `ExcalidrawElement`: a **version number**. Whenever a peer edits an element -- changing its color, deleting it, resizing it, etc -- it increments the version number of that element before sending the state to its peers. Then, when we merge multiple peers state together, we throw out old versions of each element and just keep the latest ones. https://excalidraw.com/#json=5147452789227520,QaCOJixahz7VLHs3eG1s7g This algorithm is simple but effective, and solved most of our collaboration problems. ## Dealing with conflicts: breaking ties The version number only solves race conditions when players are editing different elements concurrently. What if they're editing the same element concurrently? For Excalidraw, we don't really care! We think this will be a pretty rare situation, and that users will tolerate some jankiness if it happens. With that said, it is important that all peers converge on the same state. Otherwise, they will see different versions of the drawing and it will be very confusing! In order to do this, we made one final change. We added a new field called `versionNonce` to the `ExcalidrawElement`. Any time we increment the `version` of an element, we set `versionNonce` to a random integer. At merge time, if we encounter two elements with the same version number but different data (i.e. two players editing the same element at the same time), we break the tie by favoring the one with the lower `versionNonce`. This ensures that one player will deterministically "win" on every peer. ## Future work One problem we haven't solved yet is implementing multiplayer undo/redo. Our hack around this was to clear the undo/redo stack whenever you receive an update from a new peer. While this is a subpar user experience, it does solve a common case of one person doing the majority of the whiteboarding, and quickly undoing their most recent action. Maybe you can help us solve this problem! Go to [our GitHub](https://github.com/excalidraw/excalidraw), fork the repo, and contribute! ================================================ FILE: content/blog/deprecating-excalidraw-electron/index.md ================================================ --- title: Deprecating Excalidraw Electron in favor of the Web version date: 2020-12-17 author: tomayac link: https://twitter.com/tomayac image: excalidraw-icon.png --- On the [Excalidraw project](https://github.com/excalidraw), we have decided to deprecate [Excalidraw Desktop](https://github.com/excalidraw/excalidraw-desktop), an [Electron](https://www.electronjs.org/) wrapper for Excalidraw, in favor of the web version that you can—and always could—find at [excalidraw.com](https://excalidraw.com/). After a careful analysis, we have decided that [Progressive Web App](https://web.dev/pwa/) (PWA) is the future we want to build upon. Read on to learn why. ## How Excalidraw Desktop came into being Soon after [@vjeux](https://twitter.com/vjeux) created the initial version of Excalidraw in January 2020 and [blogged about it](/reflections-on-excalidraw/), he proposed the following in [Issue #561](https://github.com/excalidraw/excalidraw/issues/561#issue-555138343): > Would be great to wrap Excalidraw within Electron (or equivalent) and publish it as a [platform-specific] application to the various app stores. The immediate reaction by [@voluntadpear](https://github.com/voluntadpear) was to suggest: > What about making it a PWA instead? Android currently supports adding them to the Play Store as Trusted Web Activities and hopefully iOS will do the same soon. On Desktop, Chrome lets you download a desktop shortcut to a PWA. The decision that [@vjeux](https://github.com/vjeux) took in the end was simple: > We should do both :) While work on converting the version of Excalidraw into a PWA was started by [@voluntadpear](https://github.com/voluntadpear) and later others, [@lipis](https://github.com/lipis) independently [went ahead](https://github.com/excalidraw/excalidraw/issues/561#issuecomment-579573783) and created a [separate repo](https://github.com/excalidraw/excalidraw-desktop) for Excalidraw Desktop. To this day, the initial goal set by [@vjeux](https://github.com/vjeux), that is, to submit Excalidraw to the various app stores, has not been reached yet. Honestly, no one has even started the submission process to any of the stores. But why is that? Before I answer, let's look at Electron, the platform. ## What is Electron? The unique selling point of [Electron](https://www.electronjs.org/) is that it allows you to _"build cross-platform desktop apps with JavaScript, HTML, and CSS"_. Apps built with Electron are _"compatible with Mac, Windows, and Linux"_, that is, _"Electron apps build and run on three platforms"_. According to the homepage, the hard parts that Electron makes easy are [automatic updates](https://www.electronjs.org/docs/api/auto-updater), [system-level menus and notifications](https://www.electronjs.org/docs/api/menu), [crash reporting](https://www.electronjs.org/docs/api/crash-reporter), [debugging and profiling](https://www.electronjs.org/docs/api/content-tracing), and [Windows installers](https://www.electronjs.org/docs/api/auto-updater#windows). Turns out, some of the promised features need a detailed look at the small print. - For example, automatic updates _"are [currently] only [supported] on macOS and Windows. There is no built-in support for auto-updater on Linux, so it is recommended to use the distribution's package manager to update your app"_. - Developers can create system-level menus by calling `Menu.setApplicationMenu(menu)`. On Windows and Linux, the menu will be set as each window's top menu, while on macOS there are many system-defined standard menus, like the [Services](https://developer.apple.com/documentation/appkit/nsapplication/1428608-servicesmenu?language=objc) menu. To make one's menus a standard menu, developers should set their menu's `role` accordingly, and Electron will recognize them and make them become standard menus. This means that a lot of menu-related code will use the following platform check: `const isMac = process.platform === 'darwin'`. - Windows installers can be made with [windows-installer](https://github.com/electron/windows-installer). The README of the project highlights that _"for a production app you need to sign your application. Internet Explorer's SmartScreen filter will block your app from being downloaded, and many anti-virus vendors will consider your app as malware unless you obtain a valid cert"_ [sic]. Looking at just these three examples, it is clear that Electron is far from "write once, run everywhere". Distributing an app on app stores requires [code signing](https://www.electronjs.org/docs/tutorial/code-signing), a security technology for certifying app ownership. Packaging an app requires using tools like [electron-forge](https://github.com/electron-userland/electron-forge) and thinking about where to host packages for app updates. It gets complex relatively quickly, especially when the objective truly is cross platform support. I want to note that it is _absolutely_ possible to create stunning Electron apps with enough effort and dedication. For Excalidraw Desktop, we were not there. ## Where Excalidraw Desktop left off Excalidraw Desktop so far is basically the Excalidraw web app bundled as an [`.asar`](https://github.com/electron/asar) file with an added **About Excalidraw** window. The look and feel of the application is almost identical to the web version.
The Excalidraw Desktop application running in an Electron wrapper.
Excalidraw Desktop is almost indistinguishable from the Web version
The Excalidraw Desktop 'About' window displaying the version of the Electron wrapper and the Web app.
The About Excalidraw menu providing insights into the versions
On macOS, there is now a system-level menu at the top of the application, but since none of the menu actions—apart from **Close Window** and **About Excalidraw**—are hooked up to to anything, the menu is, in its current state, pretty useless. Meanwhile, all actions can of course be performed via the regular Excalidraw toolbars and the context menu.
The Excalidraw Desktop menu bar on macOS with the 'File', 'Close Window' menu item selected.
The menu bar of Excalidraw Desktop on macOS
We use [electron-builder](https://github.com/electron-userland/electron-builder), which supports [file type associations](https://www.electron.build/configuration/configuration#PlatformSpecificBuildOptions-fileAssociations). By double-clicking an `.excalidraw` file, ideally the Excalidraw Desktop app should open. The relevant excerpt of our `electron-builder.json` file looks like this: ```json { "fileAssociations": [ { "ext": "excalidraw", "name": "Excalidraw", "description": "Excalidraw file", "role": "Editor", "mimeType": "application/json" } ] } ``` Unfortunately, in practice, this does not always work as intended, since, depending on the installation type (for the current user, for all users), apps on Windows 10 do not have the rights to associate a file type to themselves. These shortcomings and the pending work to make the experience truly app-like on _all_ platforms (which, again, with enough effort _is_ possible) were a strong argument for us to reconsider our investment in Excalidraw Desktop. The way bigger argument for us, though, was that we foresee that for _our_ use case, we do not need all the features Electron offers. The grown and still growing set of capabilities of the web serves us equally well, if not better. ## How the web serves us today and in the future Even in 2020, [jQuery](https://jquery.com/) is still [incredibly popular](https://almanac.httparchive.org/en/2020/javascript#libraries). For many developers it has become a habit to use it, despite the fact that today they [might not need jQuery](http://youmightnotneedjquery.com/). There is a similar resource for Electron, aptly called [You Might Not Need Electron](https://youmightnotneedelectron.com/). Let me outline why we think we do not need Electron. ### Installable Progressive Web App Excalidraw today is an [installable](https://web.dev/installable/) Progressive Web App with a [service worker](https://excalidraw.com/service-worker.js) and a [Web App Manifest](https://excalidraw.com/manifest.json). It caches all its resources in two caches, one for fonts and font-related CSS, and one for everything else.
Chrome DevTools Application tab showing the two Excalidraw caches.
Excalidraw's cache contents
This means the application is fully offline-capable and can run without a network connection. Chromium-based browsers on both desktop and mobile prompt the user to install the app. You can see the installation prompt in the screenshot below.
Excalidraw prompting the user to install the app in Chrome on macOS.
The Excalidraw install dialog in Chrome
Excalidraw is configured to run as a standalone application, so when you install it, you get an app that runs in its own window. It is fully integrated in the operating system's multitasking UI and gets its own app icon on the home screen, Dock, or task bar; depending on the platform where you install it.
Excalidraw running in its own window.
The Excalidraw PWA in a standalone window
Excalidraw icon on the macOS Dock.
The Excalidraw icon on the macOS Dock
### File system access Excalidraw uses [browser-fs-access](https://github.com/GoogleChromeLabs/browser-fs-access) for accessing the file system of the operating system. On supporting browsers, this allows for a true open→edit→save workflow and actual over-saving and "save as", with a transparent fallback for other browsers. You can learn more about this feature in my blog post [Reading and writing files and directories with the browser-fs-access library](/browser-fs-access/). ### Drag and drop support Files can be dragged and dropped onto the Excalidraw window just as in platform-specific applications. On a browser that supports the [File System Access API](https://web.dev/file-system-access/), a dropped file can be immediately edited and the modifications be saved to the original file. This is so intuitive that you sometimes forget that you are dealing with a web app. ### Clipboard access Excalidraw works well with the operating system's clipboard. Entire Excalidraw drawings or also just individual objects can be copied and pasted in `image/png` and `image/svg+xml` formats, allowing for an easy integration with other platform-specific tools like [Inkscape](https://inkscape.org/) or web-based tools like [SVGOMG](https://jakearchibald.github.io/svgomg/).
Excalidraw context menu showing the 'copy to clipboard as SVG' and 'copy to clipboard as PNG' menu items.
The Excalidraw context menu offering clipboard actions
### File handling Excalidraw already supports the experimental [File Handling API](https://web.dev/file-handling/), which means `.excalidraw` files can be double-clicked in the operating system's file manager and open directly in the Excalidraw app, since Excalidraw registers as a file handler for `.excalidraw` files in the operating system. ### Declarative link capturing Excalidraw drawings can be shared by link. Here is an [example](https://excalidraw.com/#json=4646308765761536,jwZJW8JsOM75vdhqG2nBgA). In the future, if people have Excalidraw installed as a PWA, such links will not open in a browser tab, but launch a new standalone window. Pending implementation, this will work thanks to [declarative link capturing](https://github.com/WICG/sw-launch/blob/master/declarative_link_capturing.md), an, at the time of writing, bleeding-edge proposal for a new web platform feature. ## Conclusion The web has come a long way, with more and more features landing in browsers that only a couple of years or even months ago were unthinkable on the web and exclusive to platform-specific applications. Excalidraw is at the forefront of what is possible in the browser, all while acknowledging that not all browsers on all platforms support each feature we use. By betting on a progressive enhancement strategy, we enjoy the latest and greatest wherever possible, but without leaving anyone behind. Best viewed in _any_ browser. Electron has served us well, but in 2020 and beyond, we can live without it. Oh, and for that objective of [@vjeux](https://github/com/vjeux): since the Android Play Store now accepts PWAs in a container format called [Trusted Web Activity](https://web.dev/using-a-pwa-in-your-android-app/) and since the [Microsoft Store supports PWAs](https://docs.microsoft.com/en-us/microsoft-edge/progressive-web-apps-edgehtml/microsoft-store), too, you can expect Excalidraw in these stores in the not too distant future. Meanwhile, you can always use and install [Excalidraw in and from the browser](https://excalidraw.com/). ## Acknowledgements This article was reviewed by [@lipis](https://github.com/lipis), [@dwelle](https://github.com/dwelle), and [Joe Medley](https://github.com/jpmedley). ================================================ FILE: content/blog/enabling-translations/index.md ================================================ --- title: Enabling Translations date: 2020-04-16 author: Lipis link: https://twitter.com/lipis --- From the early days people asked for [Excalidraw](https://excalidraw.com) to be translated to other languages. Translation infrastructure and community maintenance have historically been a pain to maintain. Thankfully, with projects like [Crowdin](https://crowdin.com/project/excalidraw) and [i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector), Excalidraw is now translated in 20 languages and the whole process has been very low maintenance. ## Technical Infrastructure The first thing we need to do is to update all our literal strings that need to be translated to be using translated strings from a dictionary. ```js import { t } from "../i18n"; {t("labels.paste")}; ``` The translated strings are stored as JSON in a file per language like this. ```js // en.json { "labels": { "paste": "Paste", "selectAll": "Select all", "copy": "Copy", "copyAsPng": "Copy to clipboard as PNG", // ... } } ``` We started using a fully fledged internalization library but unfortunately it was a massive dependency and caused multiple React re-renders on startup and added one long round-trip. So instead [we rolled our own](https://github.com/excalidraw/excalidraw/blob/master/src/i18n.ts) in less than 100 lines of code. Since we don't have that many strings, we decided to bundle all the languages by default to avoid an expensive roundtrip during startup if you're not using English. For this we use `require`. ```js export const languages = [ { lng: "en", label: "English", data: require("./locales/en.json") }, { lng: "bg-BG", label: "Български", data: require("./locales/bg-BG.json") }, { lng: "de-DE", label: "Deutsch", data: require("./locales/de-DE.json") }, // ... ]; ``` The `t` function is pretty simple, where it splits the path by `.` and looks up both the current and fallback languages. ```ts // t("labels.paste") // + current {"labels": {"paste": "Coller"}} // + fallback {"labels": {"paste": "Paste"}} // -> "Coller" export function t(path: string, replacement?: { [key: string]: string }) { const parts = path.split("."); return ( findPartsForData(currentLanguage.data, parts) || findPartsForData(fallbackLanguage.data, parts) ); } ``` The only external library we're using is [i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector) which has a lot of great heuristics to figure out what language the user is currently using. Finally the last piece of the puzzle is to be able to change the language. We opted for a simple ` { this.input = ref; }} onFocus={this.handleFocus} onBlur={this.handleBlur} className="react-toggle-screenreader-only" type="checkbox" aria-label="Switch between Dark and Light mode" /> ); } } ================================================ FILE: src/components/excalidraw.js ================================================ import React from "react"; import logoPath from "../../content/assets/logo.png"; import { rhythm } from "../utils/typography"; const Excalidraw = () => { const logo = ( excalidraw ); return ( {logo} Open Excalidraw ); }; export default Excalidraw; ================================================ FILE: src/components/layout.js ================================================ import { Link } from "gatsby"; import { ThemeToggler } from "gatsby-plugin-dark-mode"; import React from "react"; import moon from "../assets/moon.png"; import sun from "../assets/sun.png"; import { rhythm } from "../utils/typography"; import Excalidraw from "./excalidraw"; import "./layoutStyles.css"; import Toggle from "./Toggle"; const Layout = ({ location, title, children, parentClassName }) => { const rootPath = `${__PATH_PREFIX__}/`; return (
{({ theme, toggleTheme }) => { return ( ), unchecked: ( presentation ), }} checked={theme === "dark"} onChange={() => { toggleTheme(theme === "light" ? "dark" : "light"); }} /> ); }}
{location.pathname !== rootPath && ( All posts )}
{location.pathname === rootPath ?

{title}

: null}
{children}
); }; export default Layout; ================================================ FILE: src/components/layoutStyles.css ================================================ body { --bg: #fff; /* OC Gray 9 */ --textNormal: #212529; --textTitle: #212529; /* OC Blue 6 */ --textLink: #228be6; --hr: hsla(0, 0%, 0%, 0.2); background-color: var(--bg); font-family: system-ui, sans-serif; } body.dark { -webkit-font-smoothing: antialiased; /* OC Grey 9 */ --bg: #212529; --textNormal: #dbe4ff; --textTitle: #fff; --hr: hsla(0, 0%, 100%, 0.2); } body.dark h1, h2, h3, h4, h5, h6 { color: var(--textTitle); } h1, h2, h3 { line-height: 1.4; } h1, h1 code { font-size: 2.5rem; } body.dark, ul, p, blockquote { color: var(--textNormal); } blockquote { border-left-color: var(--textNormal); } figcaption { text-align: center; font-size: 0.9rem; } body.dark a { color: var(--textLink); } ================================================ FILE: src/components/seo.js ================================================ /** * SEO component that queries for data with * Gatsby's useStaticQuery React hook * * See: https://www.gatsbyjs.org/docs/use-static-query/ */ import { graphql, useStaticQuery } from "gatsby"; import React from "react"; import Helmet from "react-helmet"; const SEO = ({ description = "", lang = "en", meta = [], title, image }) => { const { site } = useStaticQuery( graphql` query SEO { site { siteMetadata { title description image siteUrl } } } `, ); const metaDescription = description || site.siteMetadata.description; const metaTitle = title ? `${title} | ${site.siteMetadata.title}` : site.siteMetadata.title; let metaImage = image || site.siteMetadata.image; if (!metaImage.includes("http")) { metaImage = `${site.siteMetadata.siteUrl}${metaImage}`; } return ( ); }; export default SEO; ================================================ FILE: src/pages/404.js ================================================ import React from "react"; import { graphql } from "gatsby"; import Layout from "../components/layout"; import SEO from "../components/seo"; const NotFoundPage = ({ data, location }) => { const siteTitle = data.site.siteMetadata.title; return (

Not Found

You just hit a route that doesn't exist... the sadness.

); }; export default NotFoundPage; export const pageQuery = graphql` query NotFoundPage { site { siteMetadata { title } } } `; ================================================ FILE: src/pages/index.js ================================================ import React from "react"; import { Link, graphql } from "gatsby"; import Layout from "../components/layout"; import SEO from "../components/seo"; import { rhythm } from "../utils/typography"; function BlogIndex({ data, location }) { const posts = data.allMarkdownRemark.edges; const title = data.site.siteMetadata.title; return ( {posts.map(({ node }) => { const title = node.frontmatter.title || node.fields.slug; const authors = node.frontmatter.author ?.split(",") .filter((author) => author.trim()) || []; const authorLinks = node.frontmatter.link?.split(",").filter((link) => link.trim()) || []; return (

{title}

{node.frontmatter.date} {authors.length && ( {", by "} {authors.map((author, idx) => ( <> {authorLinks[idx] || authorLinks[0] ? ( {author} ) : ( <>{author} )} {idx < authors.length - 1 && ", "} ))} )}

{node.excerpt}

); })}
); } export default BlogIndex; export const pageQuery = graphql` query BlogIndex { site { siteMetadata { title description } } allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) { edges { node { excerpt fields { slug } frontmatter { date(formatString: "MMMM DD, YYYY") title author link } } } } } `; ================================================ FILE: src/styles.css ================================================ .excalidraw-button { background-color: #fff; border-radius: 8px; border: 1px solid #868e96; box-shadow: none; font-weight: 600; transition: all 0.2s; } .excalidraw-button:hover { background-color: #f1f3f5; } svg { max-width: 100%; height: initial; width: initial; } video { max-width: 100%; } ================================================ FILE: src/templates/blog-post.js ================================================ import { graphql, Link } from "gatsby"; import React from "react"; import Layout from "../components/layout"; import SEO from "../components/seo"; import { rhythm } from "../utils/typography"; function BlogPostTemplate({ data, pageContext: { previous, next }, location }) { const post = data.markdownRemark; const siteTitle = data.site.siteMetadata.title; const editUrl = `https://github.com/excalidraw/excalidraw-blog/edit/master/${post.fileAbsolutePath.substr( post.fileAbsolutePath.indexOf("content/blog"), )}`; const discussUrl = `https://mobile.twitter.com/search?q=${encodeURIComponent( `https://blog.excalidraw.com${post.fields.slug}`, )}`; const authors = post.frontmatter.author?.split(",").filter((author) => author.trim()) || []; const authorLinks = post.frontmatter.link?.split(",").filter((link) => link.trim()) || []; let postHTML = post.html; if (postHTML.includes("")) { postHTML = postHTML.split("")[1]; } return (

{post.frontmatter.title}

{post.frontmatter.date} {authors.length && ( {", by "} {authors.map((author, idx) => ( <> {authorLinks[idx] || authorLinks[0] ? ( {author} ) : ( <>{author} )} {idx < authors.length - 1 && ", "} ))} )} {post.frontmatter.note ? ( <> {" • "} ) : null}

Discuss on Twitter {" • "} Edit on GitHub

{previous || next ? ( <>
  • {previous && ( ← {previous.frontmatter.title} )}
  • {next && ( {next.frontmatter.title} → )}
) : null} ); } export default BlogPostTemplate; export const pageQuery = graphql` query BlogPostBySlug($slug: String!) { site { siteMetadata { title } } markdownRemark(fields: { slug: { eq: $slug } }) { id excerpt html fileAbsolutePath frontmatter { title date(formatString: "MMMM DD, YYYY") note author link image { id publicURL } } fields { slug } } } `; ================================================ FILE: src/utils/typography.js ================================================ import Typography from "typography"; import Wordpress2016 from "typography-theme-wordpress-2016"; Wordpress2016.headerFontFamily = "BlinkMacSystemFont, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif".split( ", ", ); Wordpress2016.headerWeight = 700; Wordpress2016.overrideThemeStyles = () => { return { ":root": { "--ui-font": Wordpress2016.headerFontFamily.join(","), }, h1: { fontFamily: "var(--ui-font)", fontWeight: 700, }, "a.gatsby-resp-image-link": { boxShadow: "none", }, ".gatsby-highlight": { fontSize: "0.9em", }, }; }; delete Wordpress2016.googleFonts; const typography = new Typography(Wordpress2016); // Hot reload typography in development. if (process.env.NODE_ENV !== "production") { typography.injectStyles(); } export default typography; export const rhythm = typography.rhythm; export const scale = typography.scale; ================================================ FILE: static/robots.txt ================================================ User-agent: * Disallow: ================================================ FILE: vercel.json ================================================ { "cleanUrls": true, "redirects": [ { "source": "/sitemap.xml", "destination": "https://plus.excalidraw.com/blog-sitemap.xml", "permanent": true }, { "source": "/", "destination": "https://plus.excalidraw.com/blog", "permanent": true }, { "source": "/(.*)/", "destination": "https://plus.excalidraw.com/blog/$1", "permanent": true }, { "source": "/(.*)", "destination": "https://plus.excalidraw.com/blog/$1", "permanent": true } ] }