Electron Adventures: Episode 46: Viewing Files Internally

taw

Tomasz Wegrzanowski

Posted on September 9, 2021

Electron Adventures: Episode 46: Viewing Files Internally

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:

{#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}
Enter fullscreen mode Exit fullscreen mode

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:

  $: keyboardActive = !paletteOpen && !preview
Enter fullscreen mode Exit fullscreen mode

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.

  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)
    }
  }
Enter fullscreen mode Exit fullscreen mode

And event to close the preview:

  function closePreview() {
    preview = null
  }
Enter fullscreen mode Exit fullscreen mode

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.

let readTextFile = (path) => {
  return fs.readFileSync(path, "utf8");
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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:

<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} />
Enter fullscreen mode Exit fullscreen mode

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.

And simple styling to show it as full-window:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

  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)
  }
Enter fullscreen mode Exit fullscreen mode

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.

  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()
    }
  }
Enter fullscreen mode Exit fullscreen mode

And finally the focus handling when component is created:

  function focus(el) {
    el.focus()
  }
Enter fullscreen mode Exit fullscreen mode

Result

Here's preview of an image:

(image)
Episode 46 Screenshot A

And one of a text file:

Episode 46 Screenshot B

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

As usual, all the code for the episode is here.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on September 9, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related