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:
parent
1413e73015
commit
542a545c0d
17 changed files with 445 additions and 73 deletions
|
|
@ -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." };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue