feat: spellcheck menu, typewriter mode, file associations, updater hardening

English spellcheck suggestions with Add-to-dictionary in the native context menu plus a Settings toggle. Typewriter caret-centering mode. OS file associations for .md/.txt and Open File wording. Updater: release URL surfaced, single progress listener, two-byte tarball check, installer wait guard. Search/installer test suites registered; chapter-list layout polish; ignore KDE .directory files.
This commit is contained in:
avi 2026-09-19 16:38:17 -05:00
commit 542a545c0d
17 changed files with 445 additions and 73 deletions

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, session, shell } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs";
import path from "path";
@ -86,8 +86,8 @@ function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 780,
minWidth: 960,
minHeight: 640,
minWidth: 560,
minHeight: 400,
title: "Folio",
icon: path.join(__dirname, "icons", "icon.png"),
webPreferences: {
@ -97,6 +97,7 @@ function createWindow() {
},
});
forwardEditShortcuts(mainWindow);
wireContextMenu(mainWindow);
mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.webContents.on("did-finish-load", () => {
console.log("[folio] renderer loaded");
@ -127,6 +128,15 @@ function createWindow() {
app.whenReady().then(() => {
migrateLegacyRecents();
// English-only spellcheck dictionary + stored on/off preference.
try {
session.defaultSession.setSpellCheckerLanguages(["en-US"]);
if (getSetting<boolean>("spellcheckEnabled", true) === false) {
session.defaultSession.setSpellCheckerEnabled(false);
}
} catch (err) {
console.error("[folio] spellchecker setup failed:", (err as Error).message);
}
buildMenu(
getWindow,
getState,
@ -710,19 +720,57 @@ handleIpc("folio:toggleFullscreen", () => {
return win.isFullScreen();
});
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste
// via the mouse work. The roles act on whatever element is focused.
ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const menu = Menu.buildFromTemplate([
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
]);
menu.popup({ window: win, x, y });
// Right-click menu with spelling suggestions (English-only dictionary).
// Electron builds no default context menu, so without this the red
// misspelling underlines had no way to reach a suggestion. Chromium reports
// the misspelled word + candidates via the webContents event; suggestions
// replace the word in place, and "Add to dictionary" persists to the
// session's custom words.
function wireContextMenu(win: BrowserWindow) {
win.webContents.on("context-menu", (_e, params) => {
const target = getWindow();
if (!target) return;
const items: Electron.MenuItemConstructorOptions[] = [];
if (params.misspelledWord) {
const sugs = (params.dictionarySuggestions || []).slice(0, 5);
if (sugs.length) {
for (const s of sugs) {
items.push({ label: s, click: () => target.webContents.replaceMisspelling(s) });
}
} else {
items.push({ label: "(no suggestions)", enabled: false });
}
items.push({
label: "Add to dictionary",
click: () =>
target.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord),
});
items.push({ type: "separator" });
}
items.push(
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" }
);
Menu.buildFromTemplate(items).popup({ window: target, x: params.x, y: params.y });
});
}
// Spellcheck preference (English-only dictionary). Applied at startup and
// toggled live from Settings; the editor's spellcheck attribute stays on so
// Chromium underlines misspellings whenever the session checker is enabled.
handleIpc("folio:getSpellcheck", () => getSetting<boolean>("spellcheckEnabled", true));
handleIpc("folio:setSpellcheck", (_e, on: unknown) => {
const enabled = on !== false;
setSetting("spellcheckEnabled", enabled);
try {
session.defaultSession.setSpellCheckerEnabled(enabled);
} catch {
// applied on next launch if the session is not ready yet
}
return { ok: true, enabled };
});
// Theme preference. Stored as a plain string so future themes can be added
@ -901,9 +949,9 @@ handleIpc("folio:importAttachment", async () => {
// never modified. Books land in Documents, same as "New".
handleIpc("folio:importMarkdown", async () => {
const { canceled, filePaths } = await showOpenDialog({
title: "Open Markdown file(s)",
title: "Open Markdown or text file(s)",
properties: ["openFile", "multiSelections"],
filters: [{ name: "Markdown", extensions: ["md", "markdown", "txt"] }],
filters: [{ name: "Markdown and text", extensions: ["md", "markdown", "txt"] }],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
return importMarkdownFiles(filePaths, app.getPath("documents"));
@ -956,6 +1004,7 @@ interface UpdateCheckResult {
updateAvailable?: boolean;
assetName?: string;
notes?: string;
url?: string;
canInstall?: boolean;
error?: string;
}
@ -983,6 +1032,7 @@ handleIpc("folio:checkUpdate", async (): Promise<UpdateCheckResult> => {
updateAvailable: isNewer(rel.version, current),
assetName: asset ? asset.name : undefined,
notes: rel.notes,
url: rel.url,
canInstall:
!!asset &&
isTrustedDownloadUrl(asset.browser_download_url, updateConfig().base) &&
@ -1035,7 +1085,21 @@ handleIpc("folio:performUpdate", async () => {
getWindow()?.webContents.send("folio:update-progress", { received, total });
});
await pipeline(source, createWriteStream(tarball));
const head = fs.readFileSync(tarball).subarray(0, 2);
// Verify the gzip magic (2 bytes) without loading a ~100MB tarball into
// memory. Anything else means a truncated download or an HTML error page.
let head = Buffer.alloc(0);
try {
const fd = fs.openSync(tarball, "r");
try {
const buf = Buffer.alloc(2);
const n = fs.readSync(fd, buf, 0, 2, 0);
head = buf.subarray(0, n);
} finally {
fs.closeSync(fd);
}
} catch {
head = Buffer.alloc(0);
}
if (head.length < 2 || head[0] !== 0x1f || head[1] !== 0x8b) {
fs.rmSync(tarball, { force: true });
return { error: "Downloaded file is not a valid archive; update aborted." };

View file

@ -1,8 +1,7 @@
import path from "path";
import { app, dialog, Menu, BrowserWindow } from "electron";
import { app, Menu, BrowserWindow } from "electron";
import { listRecents } from "./recents.js";
import type { Recent } from "./recents.js";
import { setRecoveryRoot } from "./backup.js";
type MenuItem = Electron.MenuItemConstructorOptions;
type GetState = () => { bookPath: string | null };
@ -38,10 +37,10 @@ export function buildMenu(
submenu: [
{ label: "New", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{
label: "Open Markdown File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
},
{
label: "Open File…",
click: () => getWindow()?.webContents.send("folio:open-markdown-requested"),
},
{ type: "separator" },
{
label: "Save",

View file

@ -10,8 +10,10 @@ contextBridge.exposeInMainWorld("folio", {
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
showContextMenu: (x: number, y: number) =>
ipcRenderer.send("folio:context-menu", x, y),
// spelling (English dictionary, suggestions in the native context menu)
getSpellcheck: () => ipcRenderer.invoke("folio:getSpellcheck"),
setSpellcheck: (on: boolean) => ipcRenderer.invoke("folio:setSpellcheck", on),
// chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),

View file

@ -146,7 +146,7 @@ export function buildInstallerScript(spec: InstallerSpec): string {
`OLD="$INSTALL.old.$$"`,
"# Wait (bounded to ~2 minutes) for the app process to exit.",
"i=0",
'while kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
'while [ "$PID" -gt 0 ] 2>/dev/null && kill -0 "$PID" 2>/dev/null && [ "$i" -lt 400 ]; do sleep 0.3; i=$((i+1)); done',
'rm -rf "$NEW"',
'mkdir -p "$NEW"',
'if tar -xzf "$TARBALL" -C "$NEW"; then',

View file

@ -151,7 +151,6 @@ export function createChapterListFeature(ctx: BookCtx): ChapterListFeature {
li.draggable = false;
li.tabIndex = -1;
li.innerHTML = `
<span class="drag-handle" title="Drag to reorder">⋮</span>
<span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions">
<button class="icon-btn" data-act="color" title="Chapter color">●</button>

View file

@ -51,6 +51,45 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
});
const editorHost = $("editorHost");
// ---- typewriter mode ----
// Keeps the caret near 42% of the visible editor height by scrolling the
// ProseMirror surface (which is its own scroll container) as the selection
// moves. Renderer-local preference; nothing leaves the machine.
const TYPEWRITER_KEY = "folio:typewriter";
let typewriterOn = false;
try {
typewriterOn = localStorage.getItem(TYPEWRITER_KEY) === "1";
} catch {
typewriterOn = false;
}
function setTypewriterEnabled(on: boolean) {
typewriterOn = on !== false;
try {
localStorage.setItem(TYPEWRITER_KEY, typewriterOn ? "1" : "0");
} catch {
// private-mode storage failure: mode still applies for the session
}
if (typewriterOn) centerCaret();
}
function isTypewriterEnabled(): boolean {
return typewriterOn;
}
function centerCaret() {
if (!typewriterOn || !editor.isFocused) return;
if (!editor.state.selection.empty) return;
const scroller = editor.view.dom as HTMLElement;
const rect = scroller.getBoundingClientRect();
if (rect.height <= 0) return;
let y = 0;
try {
y = editor.view.coordsAtPos(editor.state.selection.from).top;
} catch {
return;
}
const dy = y - (rect.top + rect.height * 0.42);
if (Math.abs(dy) > 2) scroller.scrollTop += dy;
}
// The ProseMirror surface is narrower than its host for readable line
// lengths. Keep the surrounding editor area clickable so a new chapter can
// be focused without requiring a click on the centered content column.
@ -239,6 +278,8 @@ export function createEditorFeature(ctx: BookCtx): EditorFeature {
setActive("align-left", fmt.align === "left" || fmt.align === "");
setActive("align-center", fmt.align === "center");
setActive("align-right", fmt.align === "right");
// Defer to the next frame so coords reflect the updated layout.
if (typewriterOn) requestAnimationFrame(centerCaret);
}
// Ctrl/Cmd+S saves the current chapter (in addition to the menu item).

View file

@ -144,6 +144,7 @@ export function initBook(api: FolioAPI) {
// server, then offer a one-click install. The app downloads the tarball,
// hands off to a detached swap script, and relaunches itself — no manual
// reinstall. See src/main/update.ts for the safety properties.
let updateProgressWired = false;
async function checkForUpdates() {
showToast("Checking for updates…");
const res = await api.checkUpdate();
@ -164,18 +165,23 @@ export function initBook(api: FolioAPI) {
if (!res.canInstall) {
await alertMessage(
"Automatic install unavailable",
"This release has no installable Linux archive, or this Folio is not the user-local install. Download it manually from the releases page."
"This release has no installable Linux archive, or this Folio is not the user-local install." +
(res.url ? ` Download it manually from ${res.url}` : " Download it manually from the releases page.")
);
return;
}
let lastPct = -1;
api.onUpdateProgress(({ received, total }) => {
const pct = total ? Math.floor((received / total) * 100) : -1;
if (pct !== lastPct) {
// Registered once: checkForUpdates can run repeatedly per session and
// ipcRenderer.on would otherwise stack duplicate progress listeners.
if (!updateProgressWired) {
updateProgressWired = true;
let lastPct = -1;
api.onUpdateProgress(({ received, total }) => {
const pct = total ? Math.floor((received / total) * 100) : -1;
if (pct === lastPct) return;
lastPct = pct;
showToast(pct >= 0 ? `Downloading update… ${pct}%` : `Downloading update… ${(received / 1048576).toFixed(1)} MB`);
}
});
});
}
const up = await api.performUpdate();
if (up && up.error) {
await alertMessage("Update failed", up.error);

View file

@ -201,17 +201,10 @@ export function createToolbarFeature(ctx: BookCtx, editor: Editor): ToolbarFeatu
// any of those closes the dropdowns.
window.addEventListener("scroll", closeDropdowns, true);
window.addEventListener("resize", closeDropdowns);
// Electron has no default right-click menu, so show one with Cut/Copy/Paste.
window.addEventListener("contextmenu", (e) => {
// Let Chromium show its native spelling suggestions when the target is a
// misspelled word. The custom menu is used for the rest of the editor.
const target = e.target as HTMLElement | null;
if (target?.closest(".ProseMirror") && window.getSelection()?.isCollapsed) {
return;
}
e.preventDefault();
api.showContextMenu(e.clientX, e.clientY);
});
// Note: right-click is handled by the main process (webContents
// context-menu with spelling suggestions + Cut/Copy/Paste), so no DOM
// contextmenu listener here — preventing the default would suppress the
// native suggestion menu for misspelled words.
const headingSelect = $("headingSelect") as HTMLSelectElement;
headingSelect.addEventListener("change", () => {

View file

@ -39,7 +39,8 @@ export type FolioAPI = {
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>;
showContextMenu: (x: number, y: number) => void;
getSpellcheck: () => Promise<boolean>;
setSpellcheck: (on: boolean) => Promise<any>;
getBookMeta: () => Promise<BookMeta | { error: string }>;
setComments: (comments: Record<string, unknown>) => Promise<any>;
getChapterList: () => Promise<any[]>;

View file

@ -44,23 +44,29 @@ html, body {
color: var(--color-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
max-width: 100vw;
}
#root {
display: flex;
height: 100vh;
width: 100vw;
max-width: 100vw;
overflow: hidden;
}
.sidebar {
flex: 0 0 280px;
min-width: 240px;
max-width: 360px;
max-width: min(360px, 85vw);
flex-shrink: 0;
background: var(--color-panel);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
padding: var(--gap);
overflow-x: clip;
}
.sidebar-header {
@ -342,8 +348,10 @@ html, body {
.editor {
flex: 1;
display: flex;
min-width: 0;
max-width: 100%;
background: var(--color-bg);
overflow: visible;
overflow: hidden;
}
.editor-placeholder {
@ -716,23 +724,6 @@ body.dragging-active * {
font-weight: 600;
}
.drag-handle {
cursor: grab;
padding: 4px 6px;
font-size: 1.05rem;
line-height: 1;
color: var(--color-muted);
opacity: 0.6;
border-radius: 4px;
transition: opacity 0.12s ease, color 0.12s ease;
user-select: none;
}
.chapter-row:hover .drag-handle {
opacity: 0.9;
color: var(--color-on-accent);
}
.row-title {
flex: 1;
overflow: hidden;
@ -909,6 +900,7 @@ body.dragging-active * {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 0;
overflow: hidden;
}
@ -917,17 +909,22 @@ body.dragging-active * {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding: 0;
overflow: hidden;
}
.chapter-header {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
overflow-x: clip;
}
.chapter-title {
@ -936,6 +933,11 @@ body.dragging-active * {
font-weight: 600;
margin: 0;
color: var(--color-text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.word-count {
@ -946,6 +948,11 @@ body.dragging-active * {
.editor-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
row-gap: 6px;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
gap: 10px;
color: var(--color-muted);
font-size: 0.72rem;
@ -1156,9 +1163,13 @@ body.dragging-active * {
.editor-toolbar {
display: flex;
align-items: center;
flex-wrap: nowrap;
overflow: visible;
align-content: flex-start;
flex-wrap: wrap;
overflow-x: clip;
max-width: 100%;
min-width: 0;
gap: 4px;
row-gap: 6px;
padding: 6px 12px;
border-bottom: 1px solid var(--color-border);
background: var(--color-panel);
@ -1170,11 +1181,15 @@ body.dragging-active * {
.toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
row-gap: 6px;
gap: 2px;
padding-right: 8px;
margin-right: 8px;
border-right: 1px solid var(--color-border);
flex: 0 0 auto;
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
.toolbar-group:last-child {
@ -1991,3 +2006,40 @@ body:not(.dragging-active) .chapter-row {
animation: none !important;
}
}
/* ---- narrow / tiled windows (Hyprland split-screen etc.) ----
Below ~900px the sidebar + full header + toolbar can't fit side by side,
so shrink the chrome instead of overflowing past the tile edge. */
@media (max-width: 900px) {
.sidebar {
flex-basis: 220px;
min-width: 200px;
}
.brand-icon {
width: 48px;
height: 48px;
}
.chapter-header {
padding: 10px 12px;
}
.editor-toolbar {
padding: 6px 8px;
}
.toolbar-select {
max-width: 110px;
}
.toolbar-group {
padding-right: 6px;
margin-right: 6px;
}
}
@media (max-width: 640px) {
.sidebar {
flex-basis: 200px;
min-width: 180px;
}
.chapter-title {
font-size: 1.15rem;
}
}

View file

@ -57,7 +57,7 @@
<div class="welcome-actions">
<button class="button" id="welcomeNewBook">New</button>
<button class="button button-ghost" id="welcomeOpenBook">Open</button>
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown file as a new book">Open .md</button>
<button class="button button-ghost" id="welcomeOpenMd" title="Open a Markdown or text file as a new book">Open file</button>
<button class="button button-ghost" id="welcomeUpdate" title="Check for a new version of Folio">Update</button>
</div>
<div class="recents-block">