feat: add more fonts
This commit is contained in:
1 parent
2c99408e29
commit
239775f6e4
2 files changed
+268
-14
No files matched your search
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user