mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
feat(fonts): add font picker to book setup
Add a Typography section to book setup with one-click preset pairings, an advanced body/heading picker, and a live sample. Enumerate installed fonts via a new list_system_fonts Tauri command (fontdb) so they appear alongside the bundled set, with a note that system fonts may not embed in exports.
This commit is contained in:
1 parent
28367b6670
commit
9a38976000
8 files changed
+251
-4
No files matched your search
+101
-3
@@ -1,6 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { listSystemFonts } from "../ipc";
|
||||
import {
|
||||
type BookFonts,
|
||||
type FontRef,
|
||||
BUNDLED_FONTS,
|
||||
FONT_PAIRINGS,
|
||||
decodeRef,
|
||||
encodeRef,
|
||||
fontStack,
|
||||
pairingFor,
|
||||
} from "../model/fonts";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -12,6 +23,8 @@ const LANGUAGES = [
|
||||
{ value: "pt", label: "Portuguese" },
|
||||
];
|
||||
|
||||
let systemFontCache: string[] | null = null;
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -19,6 +32,7 @@ interface Draft {
|
||||
isbn: string;
|
||||
language: string;
|
||||
bleed: boolean;
|
||||
fonts: BookFonts;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -30,6 +44,30 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FontSelect({ value, system, onChange }: { value: FontRef; system: string[]; onChange: (ref: FontRef) => void }) {
|
||||
const extra = value.kind === "system" && !system.includes(value.family) ? [value.family] : [];
|
||||
return (
|
||||
<select value={encodeRef(value)} style={{ fontFamily: fontStack(value) }} onChange={(e) => onChange(decodeRef(e.target.value))}>
|
||||
<optgroup label="Bundled">
|
||||
{BUNDLED_FONTS.map((f) => (
|
||||
<option key={f.id} value={`b:${f.id}`}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{(system.length > 0 || extra.length > 0) && (
|
||||
<optgroup label="System">
|
||||
{[...extra, ...system].map((name) => (
|
||||
<option key={name} value={`s:${name}`}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Settings({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||
const book = useBook((s) => s.book);
|
||||
const setMetadata = useBook((s) => s.setMetadata);
|
||||
@@ -42,16 +80,31 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
isbn: book?.metadata.isbn ?? "",
|
||||
language: book?.metadata.language ?? "en",
|
||||
bleed: book?.settings.bleed ?? true,
|
||||
fonts: book?.settings.fonts ?? FONT_PAIRINGS[0],
|
||||
}));
|
||||
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
||||
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFontCache) return;
|
||||
listSystemFonts().then((fonts) => {
|
||||
systemFontCache = fonts;
|
||||
setSystem(fonts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
const setFont = (slot: "body" | "heading", ref: FontRef) => set({ fonts: { ...draft.fonts, [slot]: ref } });
|
||||
|
||||
const activePreset = pairingFor(draft.fonts);
|
||||
const usesSystem = draft.fonts.body.kind === "system" || draft.fonts.heading.kind === "system";
|
||||
|
||||
const save = () => {
|
||||
const { bleed, ...metadata } = draft;
|
||||
const { bleed, fonts, ...metadata } = draft;
|
||||
setMetadata(metadata);
|
||||
setSettings({ bleed });
|
||||
setSettings({ bleed, fonts });
|
||||
onSave();
|
||||
onClose();
|
||||
};
|
||||
@@ -87,6 +140,51 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="field">
|
||||
<span className="field-label">Typography</span>
|
||||
<div className="font-presets">
|
||||
{FONT_PAIRINGS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="font-preset"
|
||||
data-on={activePreset === p.id}
|
||||
onClick={() => set({ fonts: { body: p.body, heading: p.heading } })}
|
||||
>
|
||||
<span className="font-preset-demo" style={{ fontFamily: fontStack(p.heading) }}>
|
||||
Ag
|
||||
</span>
|
||||
<span className="font-preset-name">{p.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="font-advanced" onClick={() => setAdvanced((v) => !v)}>
|
||||
{advanced ? "Hide custom fonts" : "Customize fonts"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<>
|
||||
<Field label="Body font">
|
||||
<FontSelect value={draft.fonts.body} system={system} onChange={(ref) => setFont("body", ref)} />
|
||||
</Field>
|
||||
<Field label="Heading font">
|
||||
<FontSelect value={draft.fonts.heading} system={system} onChange={(ref) => setFont("heading", ref)} />
|
||||
</Field>
|
||||
{usesSystem && (
|
||||
<p className="font-note">System fonts preview here but may not embed in exported PDF or EPUB, so they can look different on other devices.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="font-sample" style={{ fontFamily: fontStack(draft.fonts.body) }}>
|
||||
<span className="font-sample-title" style={{ fontFamily: fontStack(draft.fonts.heading) }}>
|
||||
Chapter One
|
||||
</span>
|
||||
She read the first line twice, and the whole quiet house seemed to lean in to listen.
|
||||
</div>
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={draft.bleed} onChange={(e) => set({ bleed: e.target.checked })} />
|
||||
Add bleed for full-page images (print)
|
||||
|
||||
@@ -15,3 +15,12 @@ export async function compilePdf(
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function listSystemFonts(): Promise<string[]> {
|
||||
if (!isDesktop) return [];
|
||||
try {
|
||||
return await invoke<string[]>("list_system_fonts");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ export function bundledFont(id: string): BundledFont | undefined {
|
||||
return BUNDLED_FONTS.find((f) => f.id === id);
|
||||
}
|
||||
|
||||
export function encodeRef(ref: FontRef): string {
|
||||
return ref.kind === "bundled" ? `b:${ref.id}` : `s:${ref.family}`;
|
||||
}
|
||||
|
||||
export function decodeRef(value: string): FontRef {
|
||||
return value.startsWith("s:") ? { kind: "system", family: value.slice(2) } : { kind: "bundled", id: value.slice(2) };
|
||||
}
|
||||
|
||||
export function fontFamilyName(ref: FontRef): string {
|
||||
if (ref.kind === "system") return ref.family;
|
||||
return bundledFont(ref.id)?.family ?? "Literata";
|
||||
|
||||
@@ -669,6 +669,87 @@ body.resizing {
|
||||
accent-color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 9px 12px;
|
||||
min-width: 70px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--raised);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms var(--ease), box-shadow 120ms var(--ease);
|
||||
}
|
||||
|
||||
.font-preset:hover {
|
||||
border-color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-preset[data-on="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.font-preset-demo {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.font-preset-name {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.font-advanced {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.font-note {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.font-sample {
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.font-sample-title {
|
||||
display: block;
|
||||
font-size: 21px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.editor-host,
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
|
||||
Reference in new issue
Block a user