feat: add more fonts

This commit is contained in:
Steffen Becker committed 2026-05-29 19:04:33 +02:00
1 parent 2c99408e29
commit 239775f6e4
2 files changed
+268 -14

No files matched your search

+139 -14
View File
@@ -1,6 +1,12 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import {
CANDIDATE_FONTS,
detectAvailableFonts,
queryLocalFontsIfAllowed,
supportsLocalFontAccess,
} from './fontDetect';
let { editor, tick, showFormattingMarks = $bindable() }: { editor: Editor | null; tick: number; showFormattingMarks: boolean } = $props();
@@ -35,14 +41,58 @@
// Must match the first font in --font-serif in editor.css
const DEFAULT_EDITOR_FONT = 'Georgia';
const FONTS = [
{ value: 'Arial', label: 'Arial' },
{ value: 'Verdana', label: 'Verdana' },
{ value: 'Trebuchet MS', label: 'Trebuchet MS' },
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Times New Roman', label: 'Times New Roman' },
{ value: 'Courier New', label: 'Courier New' },
] as const;
// Always-shown fonts — render in the picker even when detection fails or is blocked.
const WEB_SAFE_FONTS: readonly string[] = [
'Arial', 'Verdana', 'Trebuchet MS', 'Georgia', 'Times New Roman', 'Courier New',
];
const WEB_SAFE_SET = new Set<string>(WEB_SAFE_FONTS);
const RECENT_FONTS_KEY = 'odf-editor-recent-fonts';
const MAX_RECENT_FONTS = 5;
let recentFonts = $state<string[]>([]);
let detectedFonts = $state<string[]>([]);
let allInstalledFonts = $state<string[] | null>(null);
let detectionRan = false;
const localFontAccessSupported =
typeof window !== 'undefined' && supportsLocalFontAccess();
function loadRecents(): string[] {
try {
const raw = localStorage.getItem(RECENT_FONTS_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, MAX_RECENT_FONTS);
} catch { return []; }
}
function saveRecents(fonts: string[]) {
try { localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(fonts)); } catch { /* quota or disabled */ }
}
onMount(() => { recentFonts = loadRecents(); });
function ensureDetectionRan() {
if (detectionRan) return;
detectionRan = true;
detectedFonts = detectAvailableFonts(CANDIDATE_FONTS);
}
let extraFontsList = $derived.by(() => {
const source = allInstalledFonts ?? detectedFonts;
const out: string[] = [];
const recentSet = new Set(recentFonts);
for (const f of source) {
if (!WEB_SAFE_SET.has(f) && !recentSet.has(f)) out.push(f);
}
return out.sort((a, b) => a.localeCompare(b));
});
async function showAllInstalledFonts() {
const list = await queryLocalFontsIfAllowed();
if (list && list.length > 0) allInstalledFonts = list;
}
// Returns the uniform font of the selection, or '' when fonts are mixed.
// Plain Text without an explicit mark falls back to DEFAULT_EDITOR_FONT.
@@ -248,6 +298,7 @@
// Save selection before the picker button steals focus.
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
if (!fontOpen) ensureDetectionRan();
fontOpen = !fontOpen;
}
@@ -259,6 +310,10 @@
savedFrom = null;
savedTo = null;
editor.chain().focus().setTextSelection({ from, to }).setFontFamily(value).run();
const next = [value, ...recentFonts.filter((f) => f !== value)].slice(0, MAX_RECENT_FONTS);
recentFonts = next;
saveRecents(next);
}
function fontPickerClickOutside(node: HTMLElement) {
@@ -638,14 +693,45 @@
</button>
{#if fontOpen}
<div class="font-dropdown">
{#each FONTS as font}
{#if recentFonts.length > 0}
<div class="font-section-label">Recent</div>
{#each recentFonts as font}
<button
class="font-option"
class:active={currentFont === font}
style="font-family: {font}"
onclick={() => pickFont(font)}
>{font}</button>
{/each}
{/if}
<div class="font-section-label">Web-safe</div>
{#each WEB_SAFE_FONTS as font}
<button
class="font-option"
class:active={currentFont === font.value}
style="font-family: {font.value}"
onclick={() => pickFont(font.value)}
>{font.label}</button>
class:active={currentFont === font}
style="font-family: {font}"
onclick={() => pickFont(font)}
>{font}</button>
{/each}
{#if extraFontsList.length > 0}
<div class="font-section-label">All fonts</div>
{#each extraFontsList as font}
<button
class="font-option"
class:active={currentFont === font}
style="font-family: {font}"
onclick={() => pickFont(font)}
>{font}</button>
{/each}
{/if}
{#if localFontAccessSupported && !allInstalledFonts}
<button class="font-show-all" onclick={showAllInstalledFonts}>
Load all installed fonts
</button>
{/if}
</div>
{/if}
</div>
@@ -1015,6 +1101,8 @@
top: calc(100% + 3px);
left: 0;
min-width: 100%;
max-height: 360px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
@@ -1025,6 +1113,43 @@
flex-direction: column;
}
.font-section-label {
padding: 0.4rem 0.6rem 0.2rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
font-family: var(--font-sans);
user-select: none;
}
.font-section-label:not(:first-child) {
margin-top: 4px;
border-top: 1px solid var(--color-border);
}
.font-show-all {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
margin-top: 2px;
border: none;
border-top: 1px solid var(--color-border);
border-radius: 0;
background: var(--color-surface);
color: var(--color-primary);
font-size: 0.8rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
position: sticky;
bottom: -2px;
}
.font-show-all:hover {
background: var(--color-btn-hover);
}
.font-option {
display: block;
width: 100%;
+129
View File
@@ -0,0 +1,129 @@
// Curated list of font families that ship with one or more of:
// Windows (core + ClearType + Office), macOS, iOS, common Linux distros, MS Office.
// Detection filters this down to fonts actually installed on the current machine.
export const CANDIDATE_FONTS: readonly string[] = [
// Web-safe / cross-platform staples
'Arial', 'Arial Black', 'Arial Narrow', 'Arial Rounded MT Bold',
'Courier New', 'Georgia', 'Helvetica', 'Helvetica Neue',
'Impact', 'Lucida Console', 'Lucida Sans Unicode', 'Palatino',
'Tahoma', 'Times', 'Times New Roman', 'Trebuchet MS', 'Verdana',
// Windows core + ClearType
'Calibri', 'Calibri Light', 'Cambria', 'Cambria Math',
'Candara', 'Consolas', 'Constantia', 'Corbel',
'Franklin Gothic Medium', 'Gabriola', 'Gadugi', 'Javanese Text',
'Leelawadee UI', 'Lucida Sans', 'Malgun Gothic', 'Marlett',
'Microsoft Himalaya', 'Microsoft JhengHei', 'Microsoft JhengHei UI',
'Microsoft New Tai Lue', 'Microsoft PhagsPa', 'Microsoft Sans Serif',
'Microsoft Tai Le', 'Microsoft YaHei', 'Microsoft YaHei UI',
'Microsoft Yi Baiti', 'MingLiU-ExtB', 'Mongolian Baiti',
'MS Gothic', 'MS Mincho', 'MS PGothic', 'MV Boli',
'Myanmar Text', 'Nirmala UI', 'Palatino Linotype',
'Segoe Print', 'Segoe Script', 'Segoe UI', 'Segoe UI Black',
'Segoe UI Emoji', 'Segoe UI Historic', 'Segoe UI Light',
'Segoe UI Semibold', 'Segoe UI Semilight', 'Segoe UI Symbol',
'SimSun', 'Sitka', 'Sylfaen', 'Symbol', 'Webdings', 'Wingdings',
'Yu Gothic', 'Yu Gothic UI', 'Yu Mincho',
// MS Office bundled
'Bahnschrift', 'Bookshelf Symbol 7', 'Ebrima', 'MS Reference Sans Serif',
'MS Reference Specialty', 'Plantagenet Cherokee',
// macOS system fonts
'American Typewriter', 'Andale Mono', 'Apple Chancery', 'Apple Color Emoji',
'Apple SD Gothic Neo', 'Apple Symbols', 'AppleGothic', 'AppleMyungjo',
'Avenir', 'Avenir Next', 'Avenir Next Condensed', 'Baskerville',
'Big Caslon', 'Bodoni 72', 'Bodoni 72 Oldstyle', 'Bodoni 72 Smallcaps',
'Bradley Hand', 'Brush Script MT', 'Chalkboard', 'Chalkboard SE',
'Chalkduster', 'Charter', 'Cochin', 'Comic Sans MS', 'Copperplate',
'Courier', 'Damascus', 'Didot', 'Futura', 'Geneva', 'Gill Sans',
'GungSeo', 'HeadLineA', 'Herculanum', 'Hiragino Maru Gothic Pro',
'Hiragino Mincho ProN', 'Hiragino Sans', 'Hoefler Text', 'Iowan Old Style',
'Kefa', 'Khmer Sangam MN', 'Kohinoor Bangla', 'Kohinoor Devanagari',
'Kohinoor Telugu', 'Krungthep', 'Lao Sangam MN', 'Lucida Grande',
'Luminari', 'Marker Felt', 'Menlo', 'Monaco', 'Mukta Mahee',
'Nadeem', 'New Peninim MT', 'Noteworthy', 'Optima', 'Papyrus',
'PingFang HK', 'PingFang SC', 'PingFang TC', 'PT Mono', 'PT Sans', 'PT Serif',
'Rockwell', 'Savoye LET', 'SF Compact', 'SF Mono', 'SF Pro', 'SF Pro Display',
'SF Pro Rounded', 'SF Pro Text', 'Sinhala Sangam MN', 'Skia',
'Snell Roundhand', 'STIX Two Math', 'Superclarendon', 'Tamil Sangam MN',
'Telugu Sangam MN', 'Thonburi', 'Trattatello', 'Zapf Dingbats', 'Zapfino',
// Common Linux / open-source families
'DejaVu Sans', 'DejaVu Sans Mono', 'DejaVu Serif',
'Liberation Mono', 'Liberation Sans', 'Liberation Serif',
'Linux Biolinum', 'Linux Libertine', 'Noto Sans', 'Noto Serif',
'Noto Mono', 'Source Sans Pro', 'Source Serif Pro', 'Source Code Pro',
'Ubuntu', 'Ubuntu Mono', 'Ubuntu Condensed',
'Open Sans', 'Roboto', 'Roboto Condensed', 'Roboto Mono', 'Roboto Slab',
'Lato', 'Inter', 'Fira Sans', 'Fira Code', 'Fira Mono',
'Cantarell', 'FreeMono', 'FreeSans', 'FreeSerif',
] as const;
const PROBE_TEXT = 'mmmmmmmmmlli';
const PROBE_SIZE = '72px';
const BASELINES = ['monospace', 'sans-serif', 'serif'] as const;
let baselineWidths: number[] | null = null;
let detectionCtx: CanvasRenderingContext2D | null = null;
function getCtx(): CanvasRenderingContext2D | null {
if (detectionCtx) return detectionCtx;
try {
const canvas = document.createElement('canvas');
detectionCtx = canvas.getContext('2d');
return detectionCtx;
} catch {
return null;
}
}
function measure(ctx: CanvasRenderingContext2D, font: string): number {
ctx.font = font;
return ctx.measureText(PROBE_TEXT).width;
}
function isAvailable(ctx: CanvasRenderingContext2D, family: string): boolean {
if (!baselineWidths) {
baselineWidths = BASELINES.map((b) => measure(ctx, `${PROBE_SIZE} ${b}`));
}
const escaped = family.replace(/"/g, '\\"');
for (let i = 0; i < BASELINES.length; i++) {
const w = measure(ctx, `${PROBE_SIZE} "${escaped}", ${BASELINES[i]}`);
if (w !== baselineWidths[i]) return true;
}
return false;
}
export function detectAvailableFonts(candidates: readonly string[]): string[] {
const ctx = getCtx();
if (!ctx) return [];
baselineWidths = null;
const found: string[] = [];
for (const family of candidates) {
if (isAvailable(ctx, family)) found.push(family);
}
return found;
}
interface LocalFontData { family: string }
interface QueryLocalFontsWindow {
queryLocalFonts?: () => Promise<LocalFontData[]>;
}
export function supportsLocalFontAccess(): boolean {
return typeof (window as QueryLocalFontsWindow).queryLocalFonts === 'function';
}
export async function queryLocalFontsIfAllowed(): Promise<string[] | null> {
const fn = (window as QueryLocalFontsWindow).queryLocalFonts;
if (!fn) return null;
try {
const fonts = await fn();
const families = new Set<string>();
for (const f of fonts) families.add(f.family);
return [...families].sort((a, b) => a.localeCompare(b));
} catch {
return null;
}
}