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}
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
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)
}
}
And event to close the preview:
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.
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.
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:
<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.
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>
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)
}
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()
}
}
And finally the focus handling when component is created:
function focus(el) {
el.focus()
}
Result
Here's preview of an image:
And one of a text file:
In the next episode we'll add some modal dialogs to the app.
As usual, all the code for the episode is here.
Top comments (0)