Electron Adventures: Episode 46: Viewing Files Internally
Tags: javascript, electron, svelte
Published: 2021-09-09 05:14:03

Viewing a file is an operation which should be possible without leaving the file manager.

Let's start by supporting in-program viewing of two kinds of files - images and text files.

### Component structure in `src/App.svelte`

I want to preserve the full state of the file manager - what's opened, focused, marked and so on. So `Preview` component will open and take over the whole window, but the app will still be there just hiding behind.
If I removed components which are not visible, then we'd need some extra code to restore their state when the preview is closed.

So here's the full template of `src/App.svelte`:

```html
{#if preview}
  <Preview {...preview} />
{/if}

<div class="ui">
  <header>
    File Manager
  </header>
  <Panel initialDirectory={initialDirectoryLeft} id="left" />
  <Panel initialDirectory={initialDirectoryRight} id="right" />
  <Footer />
</div>

<Keyboard active={keyboardActive} />

{#if paletteOpen}
  <CommandPalette />
{/if}
```

Only two things changed - there's now `<Preview {...preview} />` component. And keyboard shortcuts are controlled through `keyboardActive` variable.

And it should be clear that while right now we have only two modal situations - full window view (`Preview`), and over-the-app view (`CommandPalette`), most components and dialogs can fit in one of those two modes without changing the `App` much further.

Keyboard shortcuts are disabled if either of these are active:

```javascript
  $: keyboardActive = !paletteOpen && !preview
```

And we just need modify `viewFile` event. If file has one of supported image extensions, we set preview to image. If it's one of supported text extensions, we set preview to text. Otherwise we open it externally with OSX `open` program.

We assume all text files are UTF-8. At some point we should handle situation where file is not UTF-8 too.

As we're opening a file anyway, we should probably do fancy content-based type autodetection instead here. Or just reverse this logic, and open everything as text unless it's a known binary format.

```javascript
  function viewFile(path) {
    if (/\.png$/i.test(path)) {
      preview = {type: "image", path, mimeType: "image/png"}
    } else if (/\.jpe?g$/i.test(path)) {
      preview = {type: "image", path, mimeType: "image/jpeg"}
    } else if (/\.gif$/i.test(path)) {
      preview = {type: "image", path, mimeType: "image/gif"}
    } else if (/\.(js|json|md|txt|svelte)$/i.test(path)) {
      preview = {type: "text", path}
    } else {
      window.api.viewFile(path)
    }
  }
```

And event to close the preview:

```javascript
  function closePreview() {
    preview = null
  }
```

### Reading files in `preload.js`

Before we get to `Preview` component, we need two functions to read files.

`readTextFile` returns a `String`, assuming the text file is UTF-8.

```javascript
let readTextFile = (path) => {
  return fs.readFileSync(path, "utf8");
}
```

`readFileToDataUrl` returns a `data:` URL. Why don't we use `file:` URL? There are unfortunately security restrictions for reading local files. We're serving the app through `localhost:5000` not through a `file:`, so Electron blocks reading arbitrary `file:` links for security reasons. Just reading it ourselves is easier than messing up with Electron security settings.

```javascript
let readFileToDataUrl = (path, mimeType) => {
  let buffer = fs.readFileSync(path)
  return `data:${mimeType};base64,${buffer.toString("base64")}`
}
```

### `src/Preview.svelte`

This could arguably be split to text preview and image preview modes. But we'll keep it simple for now. Here's the template:

```html
<div class="preview">
  {#if type === "image"}
    <div class="image" style="background-image: url('{imageData}')" />
  {:else}
    <div class="text" tabindex="-1" use:focus>
      {text}
    </div>
  {/if}
</div>

<svelte:window on:keydown={handleKey} />
```

The only surprising part here is `tabindex="-1" use:focus`. We want the text to be scrollable with regular keyboard navigation. If you click on it, the browser will then "scroll focus" on the div, and after the click, keyboard events will scroll it. But somehow it's impossible to control the "scroll focus" programmatically. `use:focus` does nothing - unless `tabindex="-1"` is also added to make the element focusable.

Browsers distinguish "focus" (goes on inputs, is fully controllable) and "scroll focus" (goes on basically anything scrollable, is not fully controllable), [in some weird API oversight that's not been fixed in 30 years of Web existing](https://stackoverflow.com/questions/69112192/how-to-find-out-and-control-which-elements-gets-scrolling-keyboard-events).

And simple styling to show it as full-window:

```html
<style>
  .preview {
    position: fixed;
    inset: 0;
    background: #338;
    box-shadow: 0px 0px 24px #004;
    overflow-y: auto;
  }
  .image {
    height: 100%;
    width: 100%;
    background-size: contain;
    background-repeat: no-repeat;
    background-position: center;
  }
  .text {
    white-space: pre-wrap;
  }
</style>
```

And then for the script, we initialize the component differently depending on it being an image or a text preview. Which sort of suggests that we should be using nested `ImagePreview` and `TextPreview` here:

```javascript
  export let path
  export let type = undefined
  export let mimeType = undefined

  import { getContext } from "svelte"

  let { eventBus } = getContext("app")
  let app = eventBus.target("app")

  let text
  if (type === "text") {
    text = window.api.readTextFile(path)
  }

  let imageData
  if (type === "image") {
    imageData = window.api.readFileToDataUrl(path, mimeType)
  }
```

And for keyboard shortcuts we only support two - quitting (by any of Escape, F3, F10, or Q - strangely all of them quit quick preview in traditional file managers). And F4 closes the view and opens full external editor.

We don't specify it anywhere, but since we focus on scrollable text, all scrolling shortcuts like arrow keys, PageUp, PageDown, and so on will scroll it around, and so will the mouse wheel and trackpad. It's nice to have a browser sometmies, a lot of things just work.

```javascript
  function handleKey(event) {
    let {key} = event;

    if (key === "F4") {
      event.preventDefault()
      event.stopPropagation()
      app.closePreview()
      app.editFile(path)
    }
    if (key === "Escape" || key == "F3" || key === "F10" || key.toUpperCase() === "Q") {
      event.preventDefault()
      event.stopPropagation()
      app.closePreview()
    }
  }
```

And finally the focus handling when component is created:

```javascript
  function focus(el) {
    el.focus()
  }
```

### Result

Here's preview of an image:

(image)
![Episode 46 Screenshot A](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ix1dum1zf13mzvysyoo9.png)

And one of a text file:

![Episode 46 Screenshot B](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7at0ubko4rvd7bd64i2j.png)

In the next episode we'll add some modal dialogs to the app.

As usual, [all the code for the episode is here](https://github.com/taw/electron-adventures/tree/master/episode-46-viewing-files-internally).