DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on • Updated on

Electron Adventures: Episode 31: Scrolling

In previous episode we made our file manager display actual files. Unfortunately we ran into a problem with scrolling.

Let's now implement the following functionality:

  • focused element is always scrolled into view
  • Home key focuses on first element
  • End key focuses on last element
  • PageDown goes down by one page worth of items
  • PageUp goes up by one page worth of items

We could also make mouse wheel events change focused element - that's what it does in programs like mc, but it's not really functionality I use much, and there's many complications there, so I'll leave it for now.

All our modifications will be to src/Panel.svelte, everything else stays like it was in the previous episode.

Capture DOM node references

Svelte will make sure DOM tree is what we want it to be, but it doesn't really handle the scrolling, and neither does any other framework I know.

Fortunately it's very easy to do it ourselves. Let's add two variables, for capturing DOM references to each file item in the panel:

  let fileNodes = []
  let fileListNode
Enter fullscreen mode Exit fullscreen mode

In the template we just add a bunch of bind:this declarations. We can use them in loop too:

<div class="panel {position}" class:active={active}>
  <header>{directory.split("/").slice(-1)[0]}</header>
  <div class="file-list" bind:this={fileListNode}>
    {#each files as file, idx}
      <div
        class="file"
        class:focused={idx === focusedIdx}
        class:selected={selected.includes(idx)}
        on:click|preventDefault={() => onclick(idx)}
        on:contextmenu|preventDefault={() => onrightclick(idx)}
        bind:this={fileNodes[idx]}
      >{file.name}</div>
    {/each}
  </div>
</div>

<svelte:window on:keydown={handleKey}/>
Enter fullscreen mode Exit fullscreen mode

Function to change focus

We now have so many ways to change focus, let's write a single function that does it. It will handle bounds checks too, and scroll element into view.

  let focusOn = (idx) => {
    focusedIdx = idx
    if (focusedIdx > filesCount - 1) {
      focusedIdx = filesCount - 1
    }
    if (focusedIdx < 0) {
      focusedIdx = 0
    }
    if (fileNodes[focusedIdx]) {
      fileNodes[focusedIdx].scrollIntoViewIfNeeded(true)
    }
  }
Enter fullscreen mode Exit fullscreen mode

Element.scrollIntoViewIfNeeded(true) scrolls into more or less center of the parent component if element is outside the view. It does the right thing if element is at start or end, and it does nothing if element is already in the view. This isn't a perfect solution, but it's the best of scrolling model browsers provide.

Some other available modes are:

  • Element.scrollIntoViewIfNeeded(true) - scrolls to make element align with start of the visible area, only if needed
  • Element.scrollIntoView({block: "start"}) - scrolls to make element align with start of the visible area
  • Element.scrollIntoView({block: "end"}) - scrolls to make element align with end of the visible area
  • Element.scrollIntoView({block: "center"}) - scrolls to make element align with center of the visible area
  • Element.scrollIntoView({block: "nearest"}) - scrolls to make element align with the nearest edge of the visible area

Instead of using any of these modes, we can do our own calculations. Or use some library for this, it's just basic DOM scrolling, nothing specific to either Electron, or Svelte.

By the way, this is a Chrome feature that's not universally supported, so if you're making a website you probably shouldn't use it yet without some fallbacks. Fortunately we ship the app with our own Chrome, so we can get away with it!

All the functions to navigate the file list

  let onclick = (idx) => {
    onActivate()
    focusOn(idx)
  }
  let onrightclick = (idx) => {
    onActivate()
    focusOn(idx)
    flipSelected(idx)
  }
  let handleKey = (e) => {
    if (!active) {
      return
    }
    if (e.key === "ArrowDown") {
      focusOn(focusedIdx + 1)
    } else if (e.key === "ArrowUp") {
      focusOn(focusedIdx - 1)
    } else if (e.key === "PageDown") {
      focusOn(focusedIdx + pageSize())
    } else if (e.key === "PageUp") {
      focusOn(focusedIdx - pageSize())
    } else if (e.key === "Home") {
      focusOn(0)
    } else if (e.key === "End") {
      focusOn(filesCount - 1)
    } else if (e.key === " ") {
      flipSelected(focusedIdx)
      focusOn(focusedIdx + 1)
    } else {
      return
    }
    e.preventDefault()
  }
Enter fullscreen mode Exit fullscreen mode

Various mouse and keyboard events differ only by which element they want to go to, so the code is very concise. I moved e.preventDefault() out of the list with else { return }, so I don't have to repeat e.preventDefault() for every matching key.

There's one missing here - pageSize().

Page size calculation

How many elements should we scroll if user presses PageUp or PageDown? Browser APIs don't provide such information, so we do some calculations.

  • if we don't have relevant nodes, just return 16 as fallback - it's not really going to matter, if directory is still loading or has only 0 or 1 files, then PageUp and PageDown aren't going to do much
  • find where first file is located on Y axis
  • find where second file is located on Y axis
  • difference between them is how tall element is, including any padding between elements
  • find how big visible part of the file list is on Y axis
  • divide them, rounding down, that's how many elements fit in visible part of the file list
  • we do not save this result anywhere, as user might resize app window, changed font size, or such - we just recalculate it every time to avoid any stale values
  let pageSize = () => {
    if (!fileNodes[0] || !fileNodes[1] || !fileListNode) {
      return 16
    }
    let y0 = fileNodes[0].getBoundingClientRect().y
    let y1 = fileNodes[1].getBoundingClientRect().y
    let yh = fileListNode.getBoundingClientRect().height
    return Math.floor(yh / (y1 - y0))
  }
Enter fullscreen mode Exit fullscreen mode

Result

Here's the results:

Episode 31 Screenshot

In the next episode we'll add support for moving between directories.

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

Discussion (0)