@citisen/dsh-font 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +274 -14
- package/README.zh.md +120 -9
- package/lib/client.js +1911 -145
- package/lib/index.js +66 -4
- package/package.json +9 -3
- package/scripts/build-client.mjs +24 -1
- package/scripts/release.mjs +91 -0
- package/scripts/verify-client.mjs +1177 -12
- package/scripts/verify-host.mjs +59 -5
- package/scripts/verify-profile.mjs +64 -16
- package/src/client.js +1893 -145
package/src/client.js
CHANGED
|
@@ -54,6 +54,8 @@ const PLUGIN_ID = /* dsh:plugin-id */ 'dsh-font'
|
|
|
54
54
|
/** Field names — must match the host schema in `lib/index.js`. */
|
|
55
55
|
const UI_FONT_FAMILY_FIELD = 'uiFontFamily'
|
|
56
56
|
const CODE_FONT_FAMILY_FIELD = 'codeFontFamily'
|
|
57
|
+
const CODE_FONT_WEIGHT_FIELD = 'codeFontWeight'
|
|
58
|
+
const UI_FONT_WEIGHT_FIELD = 'uiFontWeight'
|
|
57
59
|
const UI_FONT_SCALE_FIELD = 'uiFontScale'
|
|
58
60
|
const CONTENT_FONT_SIZE_FIELD = 'contentFontSize'
|
|
59
61
|
const CODE_FONT_SIZE_FIELD = 'codeFontSize'
|
|
@@ -72,60 +74,1259 @@ const CONTENT_FONT_SIZE_MIN = 12
|
|
|
72
74
|
const CONTENT_FONT_SIZE_MAX = 20
|
|
73
75
|
const CODE_FONT_SIZE_MIN = 10
|
|
74
76
|
const CODE_FONT_SIZE_MAX = 20
|
|
77
|
+
/** The shipped code weight — must match `DEFAULT_CODE_FONT_WEIGHT` in the host. */
|
|
78
|
+
const DEFAULT_CODE_FONT_WEIGHT = 400
|
|
79
|
+
/**
|
|
80
|
+
* The shipped interface weight — must match `DEFAULT_UI_FONT_WEIGHT` in the
|
|
81
|
+
* host. It is the design system's own base weight, so it is the "no override"
|
|
82
|
+
* value: the interface weight rule is emitted only for something else.
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULT_UI_FONT_WEIGHT = 400
|
|
75
85
|
|
|
76
86
|
/** The interface text sizes the shipped components hard-code, in px. */
|
|
77
87
|
const UI_TEXT_STEPS = [11, 12, 13, 14, 16, 20, 24]
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
// ─── the code weight: a closed vocabulary ───────────────────────────────────
|
|
90
|
+
//
|
|
91
|
+
// The design system ships no weight token at all: every `font:` declaration in
|
|
92
|
+
// the interface is a literal, and the code ladder is literally `400`. So the
|
|
93
|
+
// weight cannot ride the family token the way the family does — `font-family`
|
|
94
|
+
// has no slot for it, and `font-family: "Geist Mono" 500, monospace` is simply
|
|
95
|
+
// an invalid declaration that would drop the whole stack. It has to be a
|
|
96
|
+
// separate `font-weight`, which is what the field below carries.
|
|
97
|
+
//
|
|
98
|
+
// The list is closed rather than a free number on purpose: a `font-weight` a
|
|
99
|
+
// family does not have is synthesized by the browser, and offering faux-bold as
|
|
100
|
+
// a normal choice is worse than not offering it. The numbers are the CSS Fonts
|
|
101
|
+
// keyword scale, which is also the scale the Local Font Access API reports the
|
|
102
|
+
// individual faces of a family on.
|
|
103
|
+
|
|
104
|
+
/** Selectable code weights, in the order the picker shows them. */
|
|
105
|
+
const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900]
|
|
106
|
+
|
|
107
|
+
/** Locale key suffix per weight — `font.weight.medium` and friends. */
|
|
108
|
+
const WEIGHT_KEYS = {
|
|
109
|
+
100: 'thin',
|
|
110
|
+
200: 'extralight',
|
|
111
|
+
300: 'light',
|
|
112
|
+
400: 'regular',
|
|
113
|
+
500: 'medium',
|
|
114
|
+
600: 'semibold',
|
|
115
|
+
700: 'bold',
|
|
116
|
+
800: 'extrabold',
|
|
117
|
+
900: 'black',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Weight words a typed family name may end with, mapped to their number.
|
|
122
|
+
*
|
|
123
|
+
* This is what makes `Geist Mono Medium` mean "Geist Mono at 500" rather than a
|
|
124
|
+
* literal family called `Geist Mono Medium`, which resolves to nothing. A
|
|
125
|
+
* variable font like Geist Mono has one family and the whole weight axis, so
|
|
126
|
+
* every one of these words — and each static `Light`/`Medium`/`Bold` face file
|
|
127
|
+
* — is a weight, never a family.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately absent: the bare numbers. `400` is far too easy to hit as part
|
|
130
|
+
* of a real family name (`Mono 2`, `JBMono 400`), and the picker has a control
|
|
131
|
+
* for the weight, so a numeric suffix is left alone.
|
|
132
|
+
*/
|
|
133
|
+
const WEIGHT_WORDS = {
|
|
134
|
+
thin: 100,
|
|
135
|
+
hairline: 100,
|
|
136
|
+
extralight: 200,
|
|
137
|
+
ultralight: 200,
|
|
138
|
+
light: 300,
|
|
139
|
+
book: 400,
|
|
140
|
+
normal: 400,
|
|
141
|
+
regular: 400,
|
|
142
|
+
roman: 400,
|
|
143
|
+
medium: 500,
|
|
144
|
+
demibold: 600,
|
|
145
|
+
semibold: 600,
|
|
146
|
+
bold: 700,
|
|
147
|
+
extrabold: 800,
|
|
148
|
+
ultrabold: 800,
|
|
149
|
+
black: 900,
|
|
150
|
+
heavy: 900,
|
|
151
|
+
extrablack: 900,
|
|
152
|
+
ultrablack: 900,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── the font query: a small language over `font-family` ────────────────────
|
|
156
|
+
//
|
|
157
|
+
// What the settings row edits is a *query*, not a CSS value:
|
|
158
|
+
//
|
|
159
|
+
// Geist Mono medium, "Zhuque Fangsong (technical preview)", monospace
|
|
160
|
+
// └─────┬────┘ └──┬─┘
|
|
161
|
+
// family weight
|
|
162
|
+
//
|
|
163
|
+
// The grammar is the CSS font-family list plus the one thing the list cannot
|
|
164
|
+
// carry: the weight. `font-family: "Geist Mono" 500, monospace` is an invalid
|
|
165
|
+
// declaration that drops the whole stack, so the weight has to be its own
|
|
166
|
+
// `font-weight` — but it can still be *written* next to the family it belongs
|
|
167
|
+
// to, which is what makes one field able to describe both. A weight is chosen
|
|
168
|
+
// for a family; having to change them in two separate places is exactly the
|
|
169
|
+
// problem this language exists to remove.
|
|
170
|
+
//
|
|
171
|
+
// query := entry ("," entry)*
|
|
172
|
+
// entry := family | weight
|
|
173
|
+
// family := '"' … '"' | "'" … "'" | word (space word)*
|
|
174
|
+
// weight := a word from WEIGHT_WORDS
|
|
175
|
+
//
|
|
176
|
+
// Only the LAST word of an unquoted entry may be a weight word, and only when
|
|
177
|
+
// the whole entry is not itself a catalogued family: `Book Antiqua` and
|
|
178
|
+
// `Franklin Gothic Medium` are real families, and stripping either would
|
|
179
|
+
// silently retarget the stack at a font nobody picked. Quoting always means
|
|
180
|
+
// "this is the family name, verbatim", so a quoted entry never splits.
|
|
181
|
+
//
|
|
182
|
+
// The weight is one value for the whole axis, so where it is written does not
|
|
183
|
+
// change what it means. The canonical form puts it after the FIRST family —
|
|
184
|
+
// the one that is in effect — where it reads as a property of that family.
|
|
185
|
+
//
|
|
186
|
+
// The parser is forgiving on purpose. This text is typed by hand into a small
|
|
187
|
+
// box, not generated, so anything it cannot place is kept as written and
|
|
188
|
+
// reported as a diagnostic instead of being silently dropped.
|
|
189
|
+
|
|
190
|
+
/** One diagnostic code the reader can raise; the row localizes each one. */
|
|
191
|
+
const QUERY_DIAGNOSTIC = {
|
|
192
|
+
unknownFamily: 'unknown-family',
|
|
193
|
+
missingWeight: 'missing-weight',
|
|
194
|
+
duplicateWeight: 'duplicate-weight',
|
|
195
|
+
unclosedQuote: 'unclosed-quote',
|
|
196
|
+
trailingText: 'trailing-text',
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The word a numeric weight is spelled with in a query: 500 -> `medium`.
|
|
201
|
+
* @param weight - a numeric weight, or anything `normalizeWeight` accepts.
|
|
202
|
+
* @returns the canonical word for it.
|
|
203
|
+
*/
|
|
204
|
+
function weightWord(weight) {
|
|
205
|
+
return WEIGHT_KEYS[normalizeWeight(weight)] ?? String(weight)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The weights one family actually has, read off its faces' style names.
|
|
210
|
+
*
|
|
211
|
+
* The Local Font Access API reports a face's `style` (`Regular`, `SemiBold`,
|
|
212
|
+
* `Bold Italic`) rather than a number, so the mapping is the same vocabulary
|
|
213
|
+
* the query itself accepts — which is also why `Semi Bold` has to be folded
|
|
214
|
+
* back into one word before the lookup: the API's spelling is not guaranteed.
|
|
215
|
+
*
|
|
216
|
+
* An empty result means "unknown", not "none": enumerating faces is
|
|
217
|
+
* permission-gated and a family may report styles this vocabulary cannot read.
|
|
218
|
+
* Callers must treat it as the absence of evidence.
|
|
219
|
+
* @param styles - the face style names of one family.
|
|
220
|
+
* @returns the distinct weights, ascending.
|
|
221
|
+
*/
|
|
222
|
+
function faceWeights(styles) {
|
|
223
|
+
if (!Array.isArray(styles)) return []
|
|
224
|
+
const found = new Set()
|
|
225
|
+
for (const style of styles) {
|
|
226
|
+
const text = String(style)
|
|
227
|
+
.toLowerCase()
|
|
228
|
+
.replace(/\b(semi|demi)\s+(?=[a-z])/g, 'semi')
|
|
229
|
+
.replace(/\b(extra|ultra)\s+(?=[a-z])/g, 'extra')
|
|
230
|
+
for (const word of text.split(/[^a-z]+/)) {
|
|
231
|
+
if (Object.hasOwn(WEIGHT_WORDS, word)) found.add(WEIGHT_WORDS[word])
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return [...found].sort((left, right) => left - right)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether a lowercase name is a generic CSS family (`monospace`, `serif`, …).
|
|
239
|
+
* Generic families are always valid in a list, but picking one is a different
|
|
240
|
+
* intent from picking an installed font, so the language colours them apart.
|
|
241
|
+
* @param name - a lowercase family name.
|
|
242
|
+
* @returns whether it is generic.
|
|
243
|
+
*/
|
|
244
|
+
function isGenericFamilyName(name) {
|
|
245
|
+
return GENERIC_FAMILIES.includes(name)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Split a query into its comma-separated entries, keeping every raw slice.
|
|
250
|
+
*
|
|
251
|
+
* The raw slices are what makes an edit able to rewrite the text without
|
|
252
|
+
* reformatting it: `lead + core + trail` is always exactly the entry as it was
|
|
253
|
+
* found, so a rewrite that only reorders entries cannot lose a space.
|
|
254
|
+
*
|
|
255
|
+
* Commas inside quotes are not separators. Backslash escapes are deliberately
|
|
256
|
+
* not interpreted — consistent with {@link parseFamilyList} — so a family name
|
|
257
|
+
* containing the quote character has to be written with the other quote style.
|
|
258
|
+
*
|
|
259
|
+
* @param text - the query.
|
|
260
|
+
* @returns one record per entry, in order; a blank query yields one empty entry.
|
|
261
|
+
*/
|
|
262
|
+
function splitQueryEntries(text) {
|
|
263
|
+
const source = typeof text === 'string' ? text : ''
|
|
264
|
+
const bounds = []
|
|
265
|
+
let start = 0
|
|
266
|
+
let quote = ''
|
|
267
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
268
|
+
const char = source[index]
|
|
269
|
+
if (quote !== '') {
|
|
270
|
+
if (char === quote) quote = ''
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (char === '"' || char === "'") {
|
|
274
|
+
quote = char
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (char === ',') {
|
|
278
|
+
bounds.push([start, index])
|
|
279
|
+
start = index + 1
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
bounds.push([start, source.length])
|
|
283
|
+
|
|
284
|
+
return bounds.map(([from, to]) => {
|
|
285
|
+
const text = source.slice(from, to)
|
|
286
|
+
const trimmed = text.trim()
|
|
287
|
+
const lead = trimmed === '' ? text : text.slice(0, text.indexOf(trimmed))
|
|
288
|
+
const core = trimmed
|
|
289
|
+
const trail = text.slice(lead.length + core.length)
|
|
290
|
+
const quoteChar = core[0]
|
|
291
|
+
const quoted = quoteChar === '"' || quoteChar === "'"
|
|
292
|
+
const close = quoted ? core.indexOf(quoteChar, 1) : -1
|
|
293
|
+
const closed = close > 0
|
|
294
|
+
// The family text: the quotes are punctuation, not part of the name, and
|
|
295
|
+
// anything after the closing quote (a weight word) still belongs to the
|
|
296
|
+
// entry, so it is kept — with the quote pair taken out of the middle.
|
|
297
|
+
const tail = closed ? core.slice(close + 1).trim() : ''
|
|
298
|
+
const inner = quoted
|
|
299
|
+
? [closed ? core.slice(1, close) : core.slice(1), tail].filter((part) => part !== '').join(' ')
|
|
300
|
+
: core
|
|
301
|
+
return {
|
|
302
|
+
text,
|
|
303
|
+
start: from,
|
|
304
|
+
end: to,
|
|
305
|
+
lead,
|
|
306
|
+
core,
|
|
307
|
+
trail,
|
|
308
|
+
quoted,
|
|
309
|
+
closed,
|
|
310
|
+
inner,
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Read one entry into the family it names, the weight word it carries, and the
|
|
317
|
+
* highlight kind of its family part.
|
|
318
|
+
*
|
|
319
|
+
* The two shapes a weight can take are both here: a bare entry whose last word
|
|
320
|
+
* is a weight (`Geist Mono medium`), and a quoted family followed by one
|
|
321
|
+
* (`"Geist Mono" medium` — which is what the canonical form writes, and why a
|
|
322
|
+
* quoted entry cannot simply mean "no weight"). A quoted name whose closing
|
|
323
|
+
* quote is the last character carries no weight, so `"Book Antiqua"` stays a
|
|
324
|
+
* name even though a bare `Book Antiqua` could split if it were not catalogued.
|
|
325
|
+
*
|
|
326
|
+
* @param entry - one record from {@link splitQueryEntries}.
|
|
327
|
+
* @param known - the lowercase catalogue.
|
|
328
|
+
* @param enumerated - whether the catalogue is authoritative.
|
|
329
|
+
* @returns `{ name, word, wordLength, kind, diagnostics }`.
|
|
330
|
+
*/
|
|
331
|
+
function readQueryEntry(entry, known, enumerated) {
|
|
332
|
+
const core = entry.core
|
|
333
|
+
const diagnostics = []
|
|
334
|
+
if (core === '') return { name: '', word: undefined, wordLength: 0, kind: 'empty', diagnostics }
|
|
335
|
+
|
|
336
|
+
const quoteChar = core[0]
|
|
337
|
+
const quote = quoteChar === '"' || quoteChar === "'" ? quoteChar : ''
|
|
338
|
+
const lower = core.toLowerCase()
|
|
339
|
+
let name = core
|
|
340
|
+
let word
|
|
341
|
+
let wordLength = 0
|
|
342
|
+
|
|
343
|
+
if (quote !== '') {
|
|
344
|
+
const close = core.indexOf(quote, 1)
|
|
345
|
+
if (close < 0) {
|
|
346
|
+
diagnostics.push({ code: QUERY_DIAGNOSTIC.unclosedQuote })
|
|
347
|
+
name = core.slice(1).trim()
|
|
348
|
+
} else {
|
|
349
|
+
name = core.slice(1, close)
|
|
350
|
+
const rest = core.slice(close + 1).trim()
|
|
351
|
+
if (rest !== '') {
|
|
352
|
+
if (Object.hasOwn(WEIGHT_WORDS, rest.toLowerCase())) {
|
|
353
|
+
word = rest.toLowerCase()
|
|
354
|
+
wordLength = rest.length
|
|
355
|
+
} else {
|
|
356
|
+
diagnostics.push({ code: QUERY_DIAGNOSTIC.trailingText, text: rest })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} else if (isGenericFamilyName(lower) || known.has(lower)) {
|
|
361
|
+
// The whole entry is one family: there is nothing to strip off it.
|
|
362
|
+
} else if (Object.hasOwn(WEIGHT_WORDS, lower)) {
|
|
363
|
+
// A bare weight word stands on its own, without a family.
|
|
364
|
+
name = ''
|
|
365
|
+
word = lower
|
|
366
|
+
wordLength = core.length
|
|
367
|
+
} else {
|
|
368
|
+
const cut = lower.lastIndexOf(' ')
|
|
369
|
+
if (cut > 0 && Object.hasOwn(WEIGHT_WORDS, lower.slice(cut + 1))) {
|
|
370
|
+
word = lower.slice(cut + 1)
|
|
371
|
+
wordLength = word.length
|
|
372
|
+
name = core.slice(0, core.length - wordLength).trim()
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const nameLower = name.trim().toLowerCase()
|
|
377
|
+
const generic = nameLower !== '' && isGenericFamilyName(nameLower)
|
|
378
|
+
const catalogued = nameLower !== '' && known.has(nameLower)
|
|
379
|
+
if (nameLower !== '' && !generic && !catalogued && enumerated) {
|
|
380
|
+
diagnostics.push({ code: QUERY_DIAGNOSTIC.unknownFamily, name: name.trim() })
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
name: name.trim(),
|
|
384
|
+
word,
|
|
385
|
+
wordLength,
|
|
386
|
+
kind:
|
|
387
|
+
nameLower === ''
|
|
388
|
+
? 'weight'
|
|
389
|
+
: generic
|
|
390
|
+
? 'generic'
|
|
391
|
+
: catalogued || !enumerated
|
|
392
|
+
? 'family'
|
|
393
|
+
: 'unknown',
|
|
394
|
+
diagnostics,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The diagnostic-free pieces a query resolves to. */
|
|
399
|
+
function emptyQueryRead() {
|
|
400
|
+
return {
|
|
401
|
+
families: [],
|
|
402
|
+
weight: undefined,
|
|
403
|
+
weightWord: undefined,
|
|
404
|
+
effective: -1,
|
|
405
|
+
diagnostics: [],
|
|
406
|
+
tokens: [],
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Read a query into families, a weight, and highlight tokens — the one pass
|
|
412
|
+
* behind both {@link parseFontQuery} and {@link fontQueryTokens}.
|
|
413
|
+
* @param text - the query.
|
|
414
|
+
* @param options - `{ catalogue, styles, enumerated }`. `enumerated` declares
|
|
415
|
+
* the catalogue authoritative (read from the machine), which is what makes an
|
|
416
|
+
* unrecognized family worth warning about.
|
|
417
|
+
* @returns `{ entries, families, weight, weightWord, effective, diagnostics, tokens }`.
|
|
418
|
+
*/
|
|
419
|
+
function readFontQuery(text, options) {
|
|
420
|
+
const read = emptyQueryRead()
|
|
421
|
+
if (typeof text !== 'string' || text === '') return read
|
|
422
|
+
|
|
423
|
+
const catalogue = Array.isArray(options?.catalogue) ? options.catalogue : []
|
|
424
|
+
const known = new Set(catalogue.map((family) => String(family).toLowerCase()))
|
|
425
|
+
const styles = options?.styles ?? {}
|
|
426
|
+
const styleKeys = new Map()
|
|
427
|
+
for (const key of Object.keys(styles)) styleKeys.set(key.toLowerCase(), key)
|
|
428
|
+
const enumerated = options?.enumerated === true
|
|
429
|
+
|
|
430
|
+
const entries = splitQueryEntries(text)
|
|
431
|
+
let sawWeight = false
|
|
432
|
+
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
const readEntry = readQueryEntry(entry, known, enumerated)
|
|
435
|
+
for (const diagnostic of readEntry.diagnostics) read.diagnostics.push(diagnostic)
|
|
436
|
+
if (readEntry.name !== '') read.families.push(readEntry.name)
|
|
437
|
+
|
|
438
|
+
if (readEntry.word !== undefined) {
|
|
439
|
+
if (!sawWeight) {
|
|
440
|
+
sawWeight = true
|
|
441
|
+
read.weight = WEIGHT_WORDS[readEntry.word]
|
|
442
|
+
read.weightWord = readEntry.word
|
|
443
|
+
} else {
|
|
444
|
+
read.diagnostics.push({ code: QUERY_DIAGNOSTIC.duplicateWeight, word: readEntry.word })
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── the highlight tokens for this entry ─────────────────────────────────
|
|
449
|
+
// They concatenate back into the input exactly, which is what lets the
|
|
450
|
+
// painted layer and the (invisible) textarea stay aligned glyph for glyph.
|
|
451
|
+
if (entry.lead !== '') read.tokens.push({ text: entry.lead, kind: 'space' })
|
|
452
|
+
if (entry.core !== '') {
|
|
453
|
+
if (readEntry.wordLength > 0 && readEntry.name !== '') {
|
|
454
|
+
read.tokens.push({
|
|
455
|
+
text: entry.core.slice(0, entry.core.length - readEntry.wordLength),
|
|
456
|
+
kind: readEntry.kind,
|
|
457
|
+
family: readEntry.name,
|
|
458
|
+
})
|
|
459
|
+
read.tokens.push({
|
|
460
|
+
text: entry.core.slice(-readEntry.wordLength),
|
|
461
|
+
kind: 'weight',
|
|
462
|
+
family: readEntry.name,
|
|
463
|
+
})
|
|
464
|
+
} else {
|
|
465
|
+
read.tokens.push({ text: entry.core, kind: readEntry.kind, family: readEntry.name })
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (entry.trail !== '') read.tokens.push({ text: entry.trail, kind: 'space' })
|
|
469
|
+
if (entry.end < text.length) read.tokens.push({ text: ',', kind: 'comma' })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── the family that is actually in effect ────────────────────────────────
|
|
473
|
+
// With an authoritative catalogue the first *installed* family wins, because
|
|
474
|
+
// that is the one the browser will paint with. Without one the catalogue is
|
|
475
|
+
// only a suggestion list, so the parser cannot second-guess the first entry.
|
|
476
|
+
if (read.families.length > 0) {
|
|
477
|
+
if (!enumerated) {
|
|
478
|
+
read.effective = 0
|
|
479
|
+
} else {
|
|
480
|
+
for (let index = 0; index < read.families.length; index += 1) {
|
|
481
|
+
const lower = read.families[index].toLowerCase()
|
|
482
|
+
if (isGenericFamilyName(lower) || known.has(lower)) {
|
|
483
|
+
read.effective = index
|
|
484
|
+
break
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── a weight the effective family does not have ──────────────────────────
|
|
491
|
+
// Only reported when the faces of that family are actually known: an empty
|
|
492
|
+
// face list means "not read", never "not installed".
|
|
493
|
+
if (read.weight !== undefined && read.effective >= 0) {
|
|
494
|
+
const family = read.families[read.effective]
|
|
495
|
+
const key = styleKeys.get(family.toLowerCase())
|
|
496
|
+
const weights = key === undefined ? [] : faceWeights(styles[key])
|
|
497
|
+
if (weights.length > 0 && !weights.includes(read.weight)) {
|
|
498
|
+
read.diagnostics.push({
|
|
499
|
+
code: QUERY_DIAGNOSTIC.missingWeight,
|
|
500
|
+
name: family,
|
|
501
|
+
weight: read.weight,
|
|
502
|
+
word: read.weightWord,
|
|
503
|
+
})
|
|
504
|
+
for (const token of read.tokens) {
|
|
505
|
+
if (token.kind === 'weight') token.kind = 'weightMissing'
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return read
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Parse a query into the two stored fields it stands for.
|
|
515
|
+
* @param text - the query.
|
|
516
|
+
* @param options - see {@link readFontQuery}.
|
|
517
|
+
* @returns `{ families, weight, weightWord, effective, diagnostics }`.
|
|
518
|
+
*/
|
|
519
|
+
function parseFontQuery(text, options) {
|
|
520
|
+
const read = readFontQuery(text, options)
|
|
521
|
+
return {
|
|
522
|
+
families: read.families,
|
|
523
|
+
weight: read.weight,
|
|
524
|
+
weightWord: read.weightWord,
|
|
525
|
+
effective: read.effective,
|
|
526
|
+
diagnostics: read.diagnostics,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Tokenize a query for the painted layer that sits behind the textarea.
|
|
532
|
+
* @param text - the query.
|
|
533
|
+
* @param options - see {@link readFontQuery}.
|
|
534
|
+
* @returns the tokens, whose `text` concatenates back into the input exactly.
|
|
535
|
+
*/
|
|
536
|
+
function fontQueryTokens(text, options) {
|
|
537
|
+
return readFontQuery(text, options).tokens
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Write families and a weight back out as the canonical query.
|
|
542
|
+
*
|
|
543
|
+
* The weight is attached to the first family — the one in effect — and is
|
|
544
|
+
* omitted entirely for `undefined`, which is how an axis says "no weight of my
|
|
545
|
+
* own". Everything else is quoted the way CSS requires.
|
|
546
|
+
* @param families - family names in priority order.
|
|
547
|
+
* @param weight - the numeric weight, or undefined.
|
|
548
|
+
* @returns the query text.
|
|
549
|
+
*/
|
|
550
|
+
function serializeFontQuery(families, weight) {
|
|
551
|
+
const list = Array.isArray(families) ? families : []
|
|
552
|
+
const word = weight === undefined || weight === null ? undefined : weightWord(weight)
|
|
553
|
+
return list
|
|
554
|
+
.map((family, index) =>
|
|
555
|
+
index === 0 && word !== undefined ? `${quoteFamily(family)} ${word}` : quoteFamily(family),
|
|
556
|
+
)
|
|
557
|
+
.filter((token) => token !== '')
|
|
558
|
+
.join(', ')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Locate the entry a caret sits in, and the word inside it being typed.
|
|
563
|
+
*
|
|
564
|
+
* The replacement range is the WHOLE entry, not the word: a family name is
|
|
565
|
+
* several words (`Geist Mono`), so completing `geist mo` has to replace both.
|
|
566
|
+
* The word is still reported, because it is what tells a weight completion
|
|
567
|
+
* (`Geist Mono b` -> `bold`) from a family completion.
|
|
568
|
+
*
|
|
569
|
+
* @param text - the query.
|
|
570
|
+
* @param caret - the caret offset.
|
|
571
|
+
* @returns `{ start, end, core, inner, quoted, head, word, weightWord, familyEnd,
|
|
572
|
+
* caretInCore }`: `inner` is the entry with its quotes taken out, `head` the
|
|
573
|
+
* entry text before the word being typed (trimmed, unquoted), `weightWord` the
|
|
574
|
+
* complete weight word the entry already ends with, and `familyEnd` where the
|
|
575
|
+
* family part of the entry stops, in core offsets.
|
|
576
|
+
*/
|
|
577
|
+
function queryContextAt(text, caret) {
|
|
578
|
+
const source = typeof text === 'string' ? text : ''
|
|
579
|
+
const position = Math.min(Math.max(Number.isFinite(caret) ? caret : 0, 0), source.length)
|
|
580
|
+
const entries = splitQueryEntries(source)
|
|
581
|
+
let entry = entries[entries.length - 1]
|
|
582
|
+
for (const candidate of entries) {
|
|
583
|
+
if (position <= candidate.end) {
|
|
584
|
+
entry = candidate
|
|
585
|
+
break
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const caretInCore = Math.max(position - entry.start - entry.lead.length, 0)
|
|
589
|
+
const before = entry.core.slice(0, caretInCore)
|
|
590
|
+
const after = entry.core.slice(before.length)
|
|
591
|
+
const left = /\S*$/.exec(before)?.[0] ?? ''
|
|
592
|
+
const right = /^\S*/.exec(after)?.[0] ?? ''
|
|
593
|
+
|
|
594
|
+
// A trailing complete weight word is not part of the family name, so the
|
|
595
|
+
// family text ends before it: that is what keeps `"Geist Mono" medium`
|
|
596
|
+
// suggesting families and weights instead of being read as one long name.
|
|
597
|
+
const lower = entry.core.toLowerCase()
|
|
598
|
+
const cut = lower.lastIndexOf(' ')
|
|
599
|
+
const tail = cut > 0 ? lower.slice(cut + 1) : ''
|
|
600
|
+
const weightWord = Object.hasOwn(WEIGHT_WORDS, tail) ? tail : undefined
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
start: entry.start,
|
|
604
|
+
end: entry.end,
|
|
605
|
+
core: entry.core,
|
|
606
|
+
inner: entry.inner,
|
|
607
|
+
quoted: entry.quoted,
|
|
608
|
+
head: unquoteName(before.slice(0, before.length - left.length).trim()),
|
|
609
|
+
word: `${left}${right}`,
|
|
610
|
+
weightWord,
|
|
611
|
+
familyEnd: weightWord === undefined ? entry.core.length : Math.max(cut, 0),
|
|
612
|
+
caretInCore,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Strip one matching pair of surrounding quotes from a name. */
|
|
617
|
+
function unquoteName(name) {
|
|
618
|
+
const text = String(name)
|
|
619
|
+
const quoteChar = text[0]
|
|
620
|
+
if ((quoteChar === '"' || quoteChar === "'") && text.length >= 2 && text.endsWith(quoteChar)) {
|
|
621
|
+
return text.slice(1, -1)
|
|
622
|
+
}
|
|
623
|
+
return text
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Exact, case-insensitive lookup of one name in a suggestion space. */
|
|
627
|
+
function findExactName(names, needle) {
|
|
628
|
+
const lower = needle.toLowerCase()
|
|
629
|
+
return names.find((name) => name.toLowerCase() === lower)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** One entry of the autocomplete list, in the order the popup shows them. */
|
|
633
|
+
const SUGGESTION_LIMIT = 40
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The whole autocomplete decision, as a pure function of the text and caret.
|
|
637
|
+
*
|
|
638
|
+
* Candidates are family names, generic keywords, and — when the entry already
|
|
639
|
+
* names a family — that family's weight words, written as `<family> <weight>`
|
|
640
|
+
* so picking one both sets the family and the weight in a single edit. The
|
|
641
|
+
* weights come from the machine's faces when they could be read, and from the
|
|
642
|
+
* closed CSS vocabulary otherwise, so the weight is always discoverable even
|
|
643
|
+
* without the Local Font Access permission.
|
|
644
|
+
*
|
|
645
|
+
* @param context - the result of {@link queryContextAt}.
|
|
646
|
+
* @param options - `{ catalogue, styles, enumerated, shippedWeight }`.
|
|
647
|
+
* `shippedWeight` is the axis's implicit weight, offered as a row that takes
|
|
648
|
+
* the word away instead of writing it.
|
|
649
|
+
* @returns `{ start, end, at, items, custom }`; `items` are ordered for display.
|
|
650
|
+
*/
|
|
651
|
+
function querySuggestions(context, options) {
|
|
652
|
+
const catalogue = Array.isArray(options?.catalogue) ? options.catalogue : []
|
|
653
|
+
const styles = options?.styles ?? {}
|
|
654
|
+
const space = [...new Set([...catalogue, ...GENERIC_FAMILIES])]
|
|
655
|
+
// The needle is the FAMILY part of the entry: a weight word the entry already
|
|
656
|
+
// carries would otherwise make `"Geist Mono" medium` match no family at all.
|
|
657
|
+
const inner = context.inner.trim()
|
|
658
|
+
const needle = context.weightWord === undefined ? inner : inner.slice(0, -(context.weightWord.length)).trim()
|
|
659
|
+
const exact = needle === '' ? undefined : findExactName(space, needle)
|
|
660
|
+
const head = context.head === '' ? undefined : findExactName(space, context.head)
|
|
661
|
+
const family = exact ?? head
|
|
662
|
+
const wordPrefix = (exact === undefined ? context.word : '').toLowerCase()
|
|
663
|
+
|
|
664
|
+
const matches = rankFamilyMatches(space, needle).slice(0, SUGGESTION_LIMIT)
|
|
665
|
+
|
|
666
|
+
const items = []
|
|
667
|
+
const families = matches.map((name) => ({
|
|
668
|
+
id: `family:${name}`,
|
|
669
|
+
kind: isGenericFamilyName(name.toLowerCase()) ? 'generic' : 'family',
|
|
670
|
+
// A completion never drops a weight the entry already states: the word
|
|
671
|
+
// travels with the pick, so swapping the family does not quietly reset the
|
|
672
|
+
// weight and picking the family already there changes nothing at all.
|
|
673
|
+
insert:
|
|
674
|
+
context.weightWord === undefined
|
|
675
|
+
? quoteFamily(name)
|
|
676
|
+
: `${quoteFamily(name)} ${context.weightWord}`,
|
|
677
|
+
name,
|
|
678
|
+
}))
|
|
679
|
+
|
|
680
|
+
// A weight is only offered once the entry names a family, so the list never
|
|
681
|
+
// fills with weights while the user is still spelling the family out.
|
|
682
|
+
const weights = []
|
|
683
|
+
if (family !== undefined) {
|
|
684
|
+
const key = Object.keys(styles).find((name) => name.toLowerCase() === family.toLowerCase())
|
|
685
|
+
const detected = key === undefined ? [] : faceWeights(styles[key])
|
|
686
|
+
const pool = detected.length > 0 ? detected : FONT_WEIGHTS
|
|
687
|
+
for (const weight of pool) {
|
|
688
|
+
const word = weightWord(weight)
|
|
689
|
+
if (wordPrefix !== '' && !word.startsWith(wordPrefix)) continue
|
|
690
|
+
weights.push({
|
|
691
|
+
id: `weight:${family}:${String(weight)}`,
|
|
692
|
+
kind: 'weight',
|
|
693
|
+
// The axis's shipped weight is implicit, so picking it takes the word
|
|
694
|
+
// away rather than spelling out a value nobody chose.
|
|
695
|
+
insert: weight === options?.shippedWeight ? quoteFamily(family) : `${quoteFamily(family)} ${word}`,
|
|
696
|
+
family,
|
|
697
|
+
word,
|
|
698
|
+
weight,
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
// The weight the entry already states leads its own list, so an Enter that
|
|
702
|
+
// accepts the highlighted row re-applies what is written instead of
|
|
703
|
+
// silently moving a complete query to another weight.
|
|
704
|
+
const current = weights.findIndex((item) => item.word === context.weightWord)
|
|
705
|
+
if (current > 0) weights.unshift(...weights.splice(current, 1))
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// The word being typed decides what leads: once the caret is past the family
|
|
709
|
+
// name, the weight is what the user is reaching for (`Geist Mono b` -> Bold),
|
|
710
|
+
// while a caret inside the name keeps the family list first (`inter t` ->
|
|
711
|
+
// Inter Tight, not Inter's Thin).
|
|
712
|
+
const atFamilyEnd = context.caretInCore >= context.familyEnd
|
|
713
|
+
const weightFirst =
|
|
714
|
+
family !== undefined && atFamilyEnd && (exact !== undefined || families.length === 0)
|
|
715
|
+
|
|
716
|
+
// Where the pick lands. A caret at the very start of a COMPLETE entry is a
|
|
717
|
+
// boundary, not an edit: the user put it before the family to place another
|
|
718
|
+
// one ahead of it — a fallback stays a fallback — which is how a family is
|
|
719
|
+
// promoted to the front without dragging anything. Anywhere else the entry
|
|
720
|
+
// under the caret is what is being written, so the pick replaces it. A weight
|
|
721
|
+
// never inserts a new entry: it completes the one it is next to, which
|
|
722
|
+
// {@link applySuggestion} enforces from the chosen row's kind.
|
|
723
|
+
const atBoundary = context.caretInCore === 0 && exact !== undefined
|
|
724
|
+
|
|
725
|
+
return {
|
|
726
|
+
start: context.start,
|
|
727
|
+
end: context.end,
|
|
728
|
+
at: atBoundary ? 'before' : 'entry',
|
|
729
|
+
items: [...(weightFirst ? weights : families), ...(weightFirst ? families : weights)].slice(
|
|
730
|
+
0,
|
|
731
|
+
SUGGESTION_LIMIT,
|
|
732
|
+
),
|
|
733
|
+
// The custom row inserts the entry AS TYPED — weight word included — while
|
|
734
|
+
// the ranking above used the family part alone.
|
|
735
|
+
custom:
|
|
736
|
+
needle !== '' && exact === undefined && !isGenericFamilyName(needle.toLowerCase())
|
|
737
|
+
? inner
|
|
738
|
+
: undefined,
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Clamp the highlighted index against a list that may have shrunk under it. */
|
|
743
|
+
function clampHighlight(items, active) {
|
|
744
|
+
return items.length === 0 ? -1 : Math.min(Math.max(active, 0), items.length - 1)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Place a chosen completion into the query.
|
|
749
|
+
*
|
|
750
|
+
* Two shapes, because the caret means two things. `at: 'entry'` replaces the
|
|
751
|
+
* entry under the caret — what completing a half-typed family needs, since a
|
|
752
|
+
* family name is several words. `at: 'before'` inserts a whole new entry ahead
|
|
753
|
+
* of the one under the caret, which is how a family is put in charge without
|
|
754
|
+
* touching the fallbacks behind it.
|
|
755
|
+
*
|
|
756
|
+
* A trailing `, ` is added after a family so the next fallback can be typed
|
|
757
|
+
* straight away — but not after a weight, which completes the entry instead of
|
|
758
|
+
* inviting another one. An empty entry at the end of the query is what the
|
|
759
|
+
* canonical form drops on commit, so the affordance never reaches the setting.
|
|
760
|
+
*
|
|
761
|
+
* @param text - the query.
|
|
762
|
+
* @param suggestion - `{ start, end, at }` from {@link querySuggestions}.
|
|
763
|
+
* @param insert - the text to place there.
|
|
764
|
+
* @param kind - the chosen item's kind, which decides the trailing comma.
|
|
765
|
+
* @returns `{ text, caret }`.
|
|
766
|
+
*/
|
|
767
|
+
function applySuggestion(text, suggestion, insert, kind) {
|
|
768
|
+
const source = typeof text === 'string' ? text : ''
|
|
769
|
+
const start = Math.min(Math.max(suggestion.start, 0), source.length)
|
|
770
|
+
const end = Math.min(Math.max(suggestion.end, start), source.length)
|
|
771
|
+
const before = source.slice(0, start)
|
|
772
|
+
// Inserting ahead of the entry keeps the whole entry; replacing it keeps only
|
|
773
|
+
// what follows it. A weight is never an insertion, whatever the caret says.
|
|
774
|
+
const insertingBefore = suggestion.at === 'before' && kind !== 'weight'
|
|
775
|
+
const after = source.slice(insertingBefore ? start : end)
|
|
776
|
+
|
|
777
|
+
if (insertingBefore) {
|
|
778
|
+
// The space the entry carried belonged to the comma before it, so it is
|
|
779
|
+
// restored rather than doubled — and the entry itself is kept verbatim.
|
|
780
|
+
const rest = after.replace(/^\s+/, '')
|
|
781
|
+
const head = before !== '' && !/\s$/.test(before) ? `${before} ` : before
|
|
782
|
+
return { text: `${head}${insert}, ${rest}`, caret: head.length + insert.length + 2 }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const trailing = after === '' && kind !== 'weight' ? ', ' : ''
|
|
786
|
+
return {
|
|
787
|
+
text: `${before}${insert}${trailing}${after}`,
|
|
788
|
+
caret: before.length + insert.length + trailing.length,
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Move the entry a caret sits in one slot earlier or later.
|
|
794
|
+
*
|
|
795
|
+
* This is the editor's answer to drag-and-drop: order is still significant (the
|
|
796
|
+
* first installed family wins), but it is edited as text, so the keyboard never
|
|
797
|
+
* has to reach for a pointer. Only the entries swap; every space and comma
|
|
798
|
+
* stays exactly where it was.
|
|
799
|
+
*
|
|
800
|
+
* @param text - the query.
|
|
801
|
+
* @param caret - the caret offset, which selects the entry to move.
|
|
802
|
+
* @param delta - `-1` for earlier, `1` for later.
|
|
803
|
+
* @returns `{ text, caret }`; unchanged when the move leaves the list.
|
|
804
|
+
*/
|
|
805
|
+
function moveFontQueryEntry(text, caret, delta) {
|
|
806
|
+
const source = typeof text === 'string' ? text : ''
|
|
807
|
+
const entries = splitQueryEntries(source)
|
|
808
|
+
if (entries.length < 2) return { text: source, caret }
|
|
809
|
+
|
|
810
|
+
let index = entries.length - 1
|
|
811
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
812
|
+
if (caret <= entries[position].end) {
|
|
813
|
+
index = position
|
|
814
|
+
break
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const target = index + delta
|
|
818
|
+
if (target < 0 || target >= entries.length) return { text: source, caret }
|
|
819
|
+
|
|
820
|
+
const cores = entries.map((entry) => entry.core)
|
|
821
|
+
const moved = cores[index]
|
|
822
|
+
cores[index] = cores[target]
|
|
823
|
+
cores[target] = moved
|
|
824
|
+
|
|
825
|
+
const parts = entries.map((entry, position) => `${entry.lead}${cores[position]}${entry.trail}`)
|
|
826
|
+
const starts = []
|
|
827
|
+
let cursor = 0
|
|
828
|
+
for (const part of parts) {
|
|
829
|
+
starts.push(cursor)
|
|
830
|
+
cursor += part.length + 1 // the comma this part is joined with
|
|
831
|
+
}
|
|
832
|
+
const offset = Math.min(
|
|
833
|
+
Math.max(caret - (entries[index].start + entries[index].lead.length), 0),
|
|
834
|
+
cores[target].length,
|
|
835
|
+
)
|
|
836
|
+
return {
|
|
837
|
+
text: parts.join(','),
|
|
838
|
+
caret: starts[target] + entries[target].lead.length + offset,
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The weight an interface step keeps when the whole interface is moved.
|
|
844
|
+
*
|
|
845
|
+
* The interface has a weight hierarchy — headings 700, table heads 500, body
|
|
846
|
+
* 400 — and setting the interface weight has to preserve the *contrast* between
|
|
847
|
+
* those steps rather than flatten it, or every heading would sink below the body
|
|
848
|
+
* once the base goes past it. So each step keeps its shipped distance from 400
|
|
849
|
+
* and is never allowed below the base.
|
|
850
|
+
*
|
|
851
|
+
* @param shipped - the step's shipped weight.
|
|
852
|
+
* @param weight - the interface weight.
|
|
853
|
+
* @returns the weight that step takes.
|
|
854
|
+
*/
|
|
855
|
+
function emphasisWeight(shipped, weight) {
|
|
856
|
+
if (weight === DEFAULT_UI_FONT_WEIGHT) return shipped
|
|
857
|
+
return Math.min(900, Math.max(shipped, shipped + weight - DEFAULT_UI_FONT_WEIGHT))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Whether a diagnostic is a syntax error or a note about the machine.
|
|
862
|
+
*
|
|
863
|
+
* Text the reader cannot make sense of is marked as an error — the red line the
|
|
864
|
+
* user is owed when their input is wrong — while a family or a weight that is
|
|
865
|
+
* merely not installed here stays a warning: it is still a valid query, and the
|
|
866
|
+
* browser may well resolve it.
|
|
867
|
+
* @param code - a `QUERY_DIAGNOSTIC` code.
|
|
868
|
+
* @returns `'error'` or `'warn'`.
|
|
869
|
+
*/
|
|
870
|
+
function diagnosticKind(code) {
|
|
871
|
+
return code === QUERY_DIAGNOSTIC.unclosedQuote || code === QUERY_DIAGNOSTIC.trailingText
|
|
872
|
+
? 'error'
|
|
873
|
+
: 'warn'
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Substitute `{name}`-style placeholders in one locale template. */
|
|
877
|
+
function fillTemplate(template, values) {
|
|
878
|
+
return String(template).replace(/\{(\w+)\}/g, (match, key) =>
|
|
879
|
+
Object.hasOwn(values, key) ? String(values[key]) : match,
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Localize one query diagnostic.
|
|
885
|
+
* @param diagnostic - a record from {@link parseFontQuery}'s `diagnostics`.
|
|
886
|
+
* @param labels - the row's copy: `unknownFamily`, `missingWeight`,
|
|
887
|
+
* `duplicateWeight`, `unclosedQuote`.
|
|
888
|
+
* @returns the message to show, or `''` for a code this build does not know.
|
|
889
|
+
*/
|
|
890
|
+
function describeDiagnostic(diagnostic, labels) {
|
|
891
|
+
switch (diagnostic.code) {
|
|
892
|
+
case QUERY_DIAGNOSTIC.unknownFamily:
|
|
893
|
+
return fillTemplate(labels.unknownFamily, { name: diagnostic.name })
|
|
894
|
+
case QUERY_DIAGNOSTIC.missingWeight:
|
|
895
|
+
return fillTemplate(labels.missingWeight, {
|
|
896
|
+
family: diagnostic.name,
|
|
897
|
+
weight: diagnostic.weight,
|
|
898
|
+
word: diagnostic.word,
|
|
899
|
+
})
|
|
900
|
+
case QUERY_DIAGNOSTIC.duplicateWeight:
|
|
901
|
+
return fillTemplate(labels.duplicateWeight, { word: diagnostic.word })
|
|
902
|
+
case QUERY_DIAGNOSTIC.unclosedQuote:
|
|
903
|
+
return labels.unclosedQuote
|
|
904
|
+
case QUERY_DIAGNOSTIC.trailingText:
|
|
905
|
+
return fillTemplate(labels.trailingText, { text: diagnostic.text })
|
|
906
|
+
default:
|
|
907
|
+
return ''
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* The number a stored weight token stands for.
|
|
913
|
+
* @param weight - a stored value or a weight word.
|
|
914
|
+
* @returns the numeric weight, falling back to the shipped one.
|
|
915
|
+
*/
|
|
916
|
+
function normalizeWeight(weight) {
|
|
917
|
+
if (typeof weight === 'number' && FONT_WEIGHTS.includes(weight)) return weight
|
|
918
|
+
if (typeof weight === 'string' && Object.hasOwn(WEIGHT_WORDS, weight.toLowerCase())) {
|
|
919
|
+
return WEIGHT_WORDS[weight.toLowerCase()]
|
|
920
|
+
}
|
|
921
|
+
return DEFAULT_CODE_FONT_WEIGHT
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ─── the font-family list: parse, serialize, discover ───────────────────────
|
|
925
|
+
//
|
|
926
|
+
// The stored value stays a plain CSS font-family string (that is what the host
|
|
927
|
+
// schema declares and what the theme token consumes), so this section is purely
|
|
928
|
+
// a presentation layer over it: parse the string into an ordered list for the
|
|
929
|
+
// chips, and serialize the chips back. The weight is the one thing the string
|
|
930
|
+
// cannot express, so it is a second field rather than anything added here.
|
|
931
|
+
|
|
932
|
+
/** Generic CSS families. Valid anywhere in a list, but only useful at the end. */
|
|
933
|
+
const GENERIC_FAMILIES = [
|
|
934
|
+
'system-ui',
|
|
935
|
+
'sans-serif',
|
|
936
|
+
'serif',
|
|
937
|
+
'monospace',
|
|
938
|
+
'cursive',
|
|
939
|
+
'fantasy',
|
|
940
|
+
'math',
|
|
941
|
+
'emoji',
|
|
942
|
+
'fangsong',
|
|
943
|
+
'ui-sans-serif',
|
|
944
|
+
'ui-serif',
|
|
945
|
+
'ui-monospace',
|
|
946
|
+
'ui-rounded',
|
|
97
947
|
]
|
|
98
948
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
949
|
+
/**
|
|
950
|
+
* Popular families offered as suggestions even when not detected. Detection
|
|
951
|
+
* cannot see every font, and a family may be installed later, so the picker
|
|
952
|
+
* must not present itself as the complete truth.
|
|
953
|
+
*/
|
|
954
|
+
const COMMON_FAMILIES = [
|
|
955
|
+
'Inter',
|
|
956
|
+
'IBM Plex Sans',
|
|
957
|
+
'IBM Plex Mono',
|
|
958
|
+
'Noto Sans',
|
|
959
|
+
'Noto Sans SC',
|
|
960
|
+
'Noto Serif',
|
|
961
|
+
'Source Han Sans SC',
|
|
962
|
+
'Source Han Serif SC',
|
|
963
|
+
'JetBrains Mono',
|
|
964
|
+
'Fira Code',
|
|
965
|
+
'Fira Sans',
|
|
966
|
+
'Cascadia Code',
|
|
967
|
+
'Cascadia Mono',
|
|
968
|
+
'Maple Mono',
|
|
969
|
+
'Roboto',
|
|
970
|
+
'Roboto Mono',
|
|
971
|
+
'Open Sans',
|
|
972
|
+
'Lato',
|
|
973
|
+
'Montserrat',
|
|
974
|
+
'Poppins',
|
|
975
|
+
'Ubuntu',
|
|
976
|
+
'Ubuntu Mono',
|
|
977
|
+
'DejaVu Sans',
|
|
978
|
+
'DejaVu Sans Mono',
|
|
979
|
+
'Hack',
|
|
980
|
+
'Inconsolata',
|
|
981
|
+
'Iosevka',
|
|
982
|
+
'Comic Sans MS',
|
|
983
|
+
'PingFang SC',
|
|
984
|
+
'Hiragino Sans GB',
|
|
985
|
+
'Microsoft YaHei',
|
|
986
|
+
'Microsoft YaHei UI',
|
|
987
|
+
'Microsoft JhengHei',
|
|
988
|
+
'SimSun',
|
|
989
|
+
'SimHei',
|
|
990
|
+
'KaiTi',
|
|
991
|
+
'Segoe UI',
|
|
992
|
+
'Segoe UI Variable',
|
|
993
|
+
'Helvetica Neue',
|
|
994
|
+
'Arial',
|
|
995
|
+
'Consolas',
|
|
996
|
+
'Menlo',
|
|
997
|
+
'Monaco',
|
|
998
|
+
'SF Mono',
|
|
999
|
+
'Courier New',
|
|
1000
|
+
'Times New Roman',
|
|
1001
|
+
'Georgia',
|
|
119
1002
|
]
|
|
120
1003
|
|
|
1004
|
+
/**
|
|
1005
|
+
* Families worth probing for when the Local Font Access API is unavailable.
|
|
1006
|
+
* Each entry costs two text measurements, so this stays curated rather than
|
|
1007
|
+
* exhaustive.
|
|
1008
|
+
*/
|
|
1009
|
+
const PROBE_FAMILIES = [
|
|
1010
|
+
...new Set([...COMMON_FAMILIES, ...GENERIC_FAMILIES]),
|
|
1011
|
+
'-apple-system',
|
|
1012
|
+
'BlinkMacSystemFont',
|
|
1013
|
+
'Meiryo',
|
|
1014
|
+
'Yu Gothic',
|
|
1015
|
+
'Malgun Gothic',
|
|
1016
|
+
'Segoe UI Emoji',
|
|
1017
|
+
'Noto Color Emoji',
|
|
1018
|
+
'Apple Color Emoji',
|
|
1019
|
+
'Cambria',
|
|
1020
|
+
'Calibri',
|
|
1021
|
+
'Candara',
|
|
1022
|
+
'Corbel',
|
|
1023
|
+
'Franklin Gothic Medium',
|
|
1024
|
+
'Trebuchet MS',
|
|
1025
|
+
'Verdana',
|
|
1026
|
+
'Tahoma',
|
|
1027
|
+
'Lucida Console',
|
|
1028
|
+
'Lucida Sans Unicode',
|
|
1029
|
+
'Palatino Linotype',
|
|
1030
|
+
'Book Antiqua',
|
|
1031
|
+
'Garamond',
|
|
1032
|
+
'FangSong',
|
|
1033
|
+
'Microsoft Himalaya',
|
|
1034
|
+
'Sarasa Mono SC',
|
|
1035
|
+
'LXGW WenKai',
|
|
1036
|
+
'HarmonyOS Sans SC',
|
|
1037
|
+
'MiSans',
|
|
1038
|
+
'Source Code Pro',
|
|
1039
|
+
'Roboto Condensed',
|
|
1040
|
+
'Roboto Slab',
|
|
1041
|
+
'PT Sans',
|
|
1042
|
+
'PT Mono',
|
|
1043
|
+
'Nunito',
|
|
1044
|
+
'Rubik',
|
|
1045
|
+
'Work Sans',
|
|
1046
|
+
'Space Mono',
|
|
1047
|
+
'Victor Mono',
|
|
1048
|
+
'Cousine',
|
|
1049
|
+
'Anonymous Pro',
|
|
1050
|
+
'Liberation Mono',
|
|
1051
|
+
'Liberation Sans',
|
|
1052
|
+
'Nimbus Mono PS',
|
|
1053
|
+
'Droid Sans Mono',
|
|
1054
|
+
]
|
|
1055
|
+
|
|
1056
|
+
/** The family used as the "not installed" baseline when probing. */
|
|
1057
|
+
const PROBE_BASELINE = 'monospace'
|
|
1058
|
+
/** Text that renders differently across families in both width and height. */
|
|
1059
|
+
const PROBE_TEXT = 'mmmmmmmmmmlliWWQ@#中永'
|
|
1060
|
+
/** Probe font size, in px. */
|
|
1061
|
+
const PROBE_SIZE = 72
|
|
1062
|
+
/** One finished discovery result. */
|
|
1063
|
+
const FONT_DISCOVERY_CACHE_KEY = 'dsh-font:discovery:v2'
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Split a CSS font-family string into its individual family names, unwrapping
|
|
1067
|
+
* quotes. Commas inside quotes are preserved, which a naive `split(',')` gets
|
|
1068
|
+
* wrong for the `"Foo, Bar"` form.
|
|
1069
|
+
* @param value - a CSS font-family value.
|
|
1070
|
+
* @returns family names in order; `[]` for a blank value.
|
|
1071
|
+
*/
|
|
1072
|
+
function parseFamilyList(value) {
|
|
1073
|
+
if (typeof value !== 'string') return []
|
|
1074
|
+
const families = []
|
|
1075
|
+
let current = ''
|
|
1076
|
+
let quote = ''
|
|
1077
|
+
for (const char of value) {
|
|
1078
|
+
if (quote !== '') {
|
|
1079
|
+
if (char === quote) quote = ''
|
|
1080
|
+
else current += char
|
|
1081
|
+
continue
|
|
1082
|
+
}
|
|
1083
|
+
if (char === '"' || char === "'") {
|
|
1084
|
+
quote = char
|
|
1085
|
+
continue
|
|
1086
|
+
}
|
|
1087
|
+
if (char === ',') {
|
|
1088
|
+
families.push(current)
|
|
1089
|
+
current = ''
|
|
1090
|
+
continue
|
|
1091
|
+
}
|
|
1092
|
+
current += char
|
|
1093
|
+
}
|
|
1094
|
+
families.push(current)
|
|
1095
|
+
return families.map((family) => family.trim()).filter((family) => family !== '')
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Render one family name for a CSS list, quoting it when CSS requires it.
|
|
1100
|
+
* A family is a sequence of identifiers, so anything with a space or a leading
|
|
1101
|
+
* digit must be quoted; names are emitted double-quoted because that is the
|
|
1102
|
+
* form the shipped defaults already use.
|
|
1103
|
+
* @param family - a bare family name.
|
|
1104
|
+
* @returns the CSS token for it.
|
|
1105
|
+
*/
|
|
1106
|
+
function quoteFamily(family) {
|
|
1107
|
+
const name = family.trim()
|
|
1108
|
+
if (name === '') return ''
|
|
1109
|
+
// A CSS-wide keyword or a generic family must not be quoted: `"sans-serif"`
|
|
1110
|
+
// would name a literal font instead of the generic family. A single leading
|
|
1111
|
+
// hyphen is part of an identifier (`-apple-system`), so it survives unquoted
|
|
1112
|
+
// too — which is what lets the shipped interface stack round-trip unchanged.
|
|
1113
|
+
if (/^-?[A-Za-z][\w-]*$/.test(name)) return name
|
|
1114
|
+
return `"${name.replaceAll('"', '')}"`
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Join family names back into a CSS font-family string.
|
|
1119
|
+
* @param families - family names in priority order.
|
|
1120
|
+
* @returns the CSS value.
|
|
1121
|
+
*/
|
|
1122
|
+
function serializeFamilyList(families) {
|
|
1123
|
+
return families.map(quoteFamily).filter((token) => token !== '').join(', ')
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Measure whether one family is actually installed.
|
|
1128
|
+
*
|
|
1129
|
+
* The technique is the classic width/height comparison: render the probe text
|
|
1130
|
+
* in `<family>, <baseline>` and again in the bare baseline. When the family is
|
|
1131
|
+
* absent both renders lay out identically, because the browser fell through to
|
|
1132
|
+
* the same baseline font. This needs no permission and works in every browser,
|
|
1133
|
+
* which is why it is the fallback rather than the primary.
|
|
1134
|
+
* @param family - family name to test.
|
|
1135
|
+
* @returns whether it renders differently from the baseline.
|
|
1136
|
+
*/
|
|
1137
|
+
function isFamilyAvailable(family) {
|
|
1138
|
+
if (typeof document === 'undefined') return false
|
|
1139
|
+
if (family === PROBE_BASELINE) return true
|
|
1140
|
+
const probe = document.createElement('span')
|
|
1141
|
+
probe.textContent = PROBE_TEXT
|
|
1142
|
+
probe.setAttribute('aria-hidden', 'true')
|
|
1143
|
+
probe.style.cssText = [
|
|
1144
|
+
'position:absolute',
|
|
1145
|
+
'left:-9999px',
|
|
1146
|
+
'top:-9999px',
|
|
1147
|
+
'visibility:hidden',
|
|
1148
|
+
'white-space:nowrap',
|
|
1149
|
+
`font-size:${String(PROBE_SIZE)}px`,
|
|
1150
|
+
'line-height:normal',
|
|
1151
|
+
].join(';')
|
|
1152
|
+
const parent = document.body ?? document.documentElement
|
|
1153
|
+
if (parent === null || parent === undefined) return false
|
|
1154
|
+
parent.appendChild(probe)
|
|
1155
|
+
try {
|
|
1156
|
+
probe.style.fontFamily = PROBE_BASELINE
|
|
1157
|
+
const baseWidth = probe.offsetWidth
|
|
1158
|
+
const baseHeight = probe.offsetHeight
|
|
1159
|
+
probe.style.fontFamily = `${quoteFamily(family)}, ${PROBE_BASELINE}`
|
|
1160
|
+
return probe.offsetWidth !== baseWidth || probe.offsetHeight !== baseHeight
|
|
1161
|
+
} finally {
|
|
1162
|
+
probe.remove()
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Ask the browser for the real installed families and their face styles.
|
|
1168
|
+
*
|
|
1169
|
+
* Chromium's Local Font Access API is the only way to enumerate actual fonts.
|
|
1170
|
+
* It is permission-gated, absent in Firefox and Safari, and — per the spec —
|
|
1171
|
+
* browsers are not obliged to return the complete list, so the result is a
|
|
1172
|
+
* supplement to the curated catalogue, never a replacement.
|
|
1173
|
+
*
|
|
1174
|
+
* The per-face `style` names (`Regular`, `Medium`, `Bold Italic`) are collected
|
|
1175
|
+
* alongside the families because they are what lets the editor offer *this*
|
|
1176
|
+
* family's weights and catch a weight the family does not have, instead of
|
|
1177
|
+
* offering the whole CSS vocabulary blindly.
|
|
1178
|
+
* @returns `{ families, styles }`, or undefined when unavailable or declined.
|
|
1179
|
+
*/
|
|
1180
|
+
async function queryInstalledFamilies() {
|
|
1181
|
+
if (typeof window === 'undefined') return undefined
|
|
1182
|
+
const query = window.queryLocalFonts
|
|
1183
|
+
if (typeof query !== 'function') return undefined
|
|
1184
|
+
try {
|
|
1185
|
+
const fonts = await query.call(window)
|
|
1186
|
+
const families = new Set()
|
|
1187
|
+
const styles = {}
|
|
1188
|
+
for (const font of fonts) {
|
|
1189
|
+
if (typeof font?.family !== 'string' || font.family.trim() === '') continue
|
|
1190
|
+
const family = font.family.trim()
|
|
1191
|
+
families.add(family)
|
|
1192
|
+
const style = typeof font.style === 'string' ? font.style.trim() : ''
|
|
1193
|
+
if (style === '') continue
|
|
1194
|
+
const list = styles[family] ?? (styles[family] = [])
|
|
1195
|
+
if (!list.includes(style)) list.push(style)
|
|
1196
|
+
}
|
|
1197
|
+
return {
|
|
1198
|
+
families: [...families].sort((left, right) => left.localeCompare(right)),
|
|
1199
|
+
styles,
|
|
1200
|
+
}
|
|
1201
|
+
} catch {
|
|
1202
|
+
// A denied or dismissed permission prompt lands here. Fall back quietly:
|
|
1203
|
+
// the picker still works, just with a smaller catalogue.
|
|
1204
|
+
return undefined
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Read the cached discovery result, if it is still valid. */
|
|
1209
|
+
function readDiscoveryCache() {
|
|
1210
|
+
try {
|
|
1211
|
+
const raw = sessionStorage.getItem(FONT_DISCOVERY_CACHE_KEY)
|
|
1212
|
+
if (raw === null) return undefined
|
|
1213
|
+
const parsed = JSON.parse(raw)
|
|
1214
|
+
if (parsed === null || typeof parsed !== 'object') return undefined
|
|
1215
|
+
if (!Array.isArray(parsed.families)) return undefined
|
|
1216
|
+
// A cache written before the face styles existed has the families but not
|
|
1217
|
+
// the weights, so it is treated as a miss rather than half-adopted.
|
|
1218
|
+
const styles = parsed.styles
|
|
1219
|
+
if (styles === null || typeof styles !== 'object' || Array.isArray(styles)) return undefined
|
|
1220
|
+
return { families: parsed.families, styles, enumerated: parsed.enumerated === true }
|
|
1221
|
+
} catch {
|
|
1222
|
+
return undefined
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Remember a discovery result for the rest of the session. */
|
|
1227
|
+
function writeDiscoveryCache(result) {
|
|
1228
|
+
try {
|
|
1229
|
+
sessionStorage.setItem(FONT_DISCOVERY_CACHE_KEY, JSON.stringify(result))
|
|
1230
|
+
} catch {
|
|
1231
|
+
/* private mode or a full quota; the in-memory copy still serves this render */
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Discover selectable families, cheapest source first: the session cache, then
|
|
1237
|
+
* the Local Font Access API when the browser offers it, then the measurement
|
|
1238
|
+
* probe. Measured families are verified as present; enumerated and curated ones
|
|
1239
|
+
* are offered without that guarantee, and the UI says so.
|
|
1240
|
+
* @returns `{ families, styles, enumerated, measured }`.
|
|
1241
|
+
*/
|
|
1242
|
+
async function discoverFamilies() {
|
|
1243
|
+
const cached = readDiscoveryCache()
|
|
1244
|
+
if (cached !== undefined) {
|
|
1245
|
+
const result = { families: cached.families, styles: cached.styles, enumerated: cached.enumerated }
|
|
1246
|
+
writeDiscoveryCache(result)
|
|
1247
|
+
return { ...result, measured: true }
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const enumerated = await queryInstalledFamilies()
|
|
1251
|
+
if (enumerated !== undefined && enumerated.families.length > 0) {
|
|
1252
|
+
const families = [...new Set([...enumerated.families, ...COMMON_FAMILIES])].sort((left, right) =>
|
|
1253
|
+
left.localeCompare(right),
|
|
1254
|
+
)
|
|
1255
|
+
const result = { families, styles: enumerated.styles, enumerated: true }
|
|
1256
|
+
writeDiscoveryCache(result)
|
|
1257
|
+
return { ...result, measured: true }
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const installed = PROBE_FAMILIES.filter((family) => {
|
|
1261
|
+
try {
|
|
1262
|
+
return isFamilyAvailable(family)
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false
|
|
1265
|
+
}
|
|
1266
|
+
})
|
|
1267
|
+
const families = [...new Set([...installed, ...COMMON_FAMILIES])].sort((left, right) =>
|
|
1268
|
+
left.localeCompare(right),
|
|
1269
|
+
)
|
|
1270
|
+
const result = { families, styles: {}, enumerated: false }
|
|
1271
|
+
writeDiscoveryCache(result)
|
|
1272
|
+
return { ...result, measured: true }
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Rank the catalogue against what the user has typed: exact match, then prefix,
|
|
1277
|
+
* then substring, then everything else. An empty query returns the catalogue
|
|
1278
|
+
* as-is so the dropdown doubles as a browse list.
|
|
1279
|
+
* @param families - the catalogue.
|
|
1280
|
+
* @param query - the current input text.
|
|
1281
|
+
* @returns the ordered candidates.
|
|
1282
|
+
*/
|
|
1283
|
+
function rankFamilyMatches(families, query) {
|
|
1284
|
+
const needle = query.trim().toLowerCase()
|
|
1285
|
+
if (needle === '') return families
|
|
1286
|
+
const exact = []
|
|
1287
|
+
const prefix = []
|
|
1288
|
+
const contains = []
|
|
1289
|
+
for (const family of families) {
|
|
1290
|
+
const lower = family.toLowerCase()
|
|
1291
|
+
if (lower === needle) exact.push(family)
|
|
1292
|
+
else if (lower.startsWith(needle)) prefix.push(family)
|
|
1293
|
+
else if (lower.includes(needle)) contains.push(family)
|
|
1294
|
+
}
|
|
1295
|
+
return [...exact, ...prefix, ...contains]
|
|
1296
|
+
}
|
|
1297
|
+
|
|
121
1298
|
/** Row copy, keyed by locale. `zh` is the key-set source of truth. */
|
|
122
1299
|
const zh = {
|
|
123
1300
|
'font.title': '字体',
|
|
124
1301
|
'font.description': '自定义界面与代码字体、字号,设置会保存到本机',
|
|
125
1302
|
'font.uiFamily': '界面字体',
|
|
126
|
-
'font.uiFamilyHint':
|
|
1303
|
+
'font.uiFamilyHint':
|
|
1304
|
+
'字体查询:字体族,后面可以跟字重,例如 Inter, "PingFang SC", sans-serif;逗号分隔,靠前的优先',
|
|
127
1305
|
'font.codeFamily': '代码字体',
|
|
128
|
-
'font.codeFamilyHint':
|
|
1306
|
+
'font.codeFamilyHint':
|
|
1307
|
+
'用于代码块、行内代码与终端等宽文本,例如 Geist Mono medium, monospace',
|
|
1308
|
+
'font.uiWeight': '界面字重',
|
|
1309
|
+
'font.codeWeight': '代码字重',
|
|
1310
|
+
'font.weightShipped': '(出厂值,未覆盖)',
|
|
1311
|
+
'font.suggestions': '字体建议',
|
|
1312
|
+
'font.familyKind': '字体族',
|
|
1313
|
+
'font.genericKind': '通用族',
|
|
1314
|
+
'font.pending': '按 Enter 应用(文本不会被改写)',
|
|
1315
|
+
'font.emptyQuery': '这个查询还是空的:没有写入任何字体,当前仍在用已保存的设置;要回到出厂值请按「恢复默认」',
|
|
1316
|
+
'font.diag.unknownFamily': '本机字体列表里没有 {name},仍会写入,浏览器可能回退到后面的字体',
|
|
1317
|
+
'font.diag.missingWeight': '{family} 在本机没有 {weight} 这个字面({word}),浏览器会合成',
|
|
1318
|
+
'font.diag.duplicateWeight': '字重只能写一次,已采用第一个;多余的 {word} 被忽略',
|
|
1319
|
+
'font.diag.unclosedQuote': '引号没有闭合,之后的内容都会被当作同一个字体名',
|
|
1320
|
+
'font.diag.trailingText': '引号后面多了“{text}”,它既不是字重也不属于这个字体名,已被忽略',
|
|
1321
|
+
'font.weight.thin': '极细',
|
|
1322
|
+
'font.weight.extralight': '特细',
|
|
1323
|
+
'font.weight.light': '细体',
|
|
1324
|
+
'font.weight.regular': '常规',
|
|
1325
|
+
'font.weight.medium': '中等',
|
|
1326
|
+
'font.weight.semibold': '半粗',
|
|
1327
|
+
'font.weight.bold': '粗体',
|
|
1328
|
+
'font.weight.extrabold': '特粗',
|
|
1329
|
+
'font.weight.black': '黑体',
|
|
129
1330
|
'font.uiScale': '界面字号',
|
|
130
1331
|
'font.uiScaleHint': '按比例缩放整个界面的文字大小',
|
|
131
1332
|
'font.contentSize': '会话正文字号',
|
|
@@ -134,9 +1335,12 @@ const zh = {
|
|
|
134
1335
|
'font.codeSizeHint': '代码块与行内代码的字号',
|
|
135
1336
|
'font.unit': 'px',
|
|
136
1337
|
'font.reset': '恢复默认',
|
|
137
|
-
'font.presets': '预设',
|
|
138
1338
|
'font.increase': '增大',
|
|
139
1339
|
'font.decrease': '减小',
|
|
1340
|
+
'font.add': '插入',
|
|
1341
|
+
'font.catalogProbed':
|
|
1342
|
+
'字体列表只包含探测到的常用字体(读取本机字体的权限不可用或被拒绝);任何字体名仍然可以直接写',
|
|
1343
|
+
'font.genericWarning': '末尾缺少通用字体族(如 sans-serif),指定字体都缺失时可能回退到意外字体',
|
|
140
1344
|
}
|
|
141
1345
|
|
|
142
1346
|
/** English dictionary, checked complete against the `zh` key set. */
|
|
@@ -145,9 +1349,38 @@ const en = {
|
|
|
145
1349
|
'font.description':
|
|
146
1350
|
'Customize the interface and code fonts and sizes; values are saved on this machine',
|
|
147
1351
|
'font.uiFamily': 'Interface font',
|
|
148
|
-
'font.uiFamilyHint':
|
|
1352
|
+
'font.uiFamilyHint':
|
|
1353
|
+
'A font query: a family, optionally followed by a weight — e.g. Inter, "PingFang SC", sans-serif. Comma-separated, earlier wins',
|
|
149
1354
|
'font.codeFamily': 'Code font',
|
|
150
|
-
'font.codeFamilyHint':
|
|
1355
|
+
'font.codeFamilyHint':
|
|
1356
|
+
'Used for code blocks, inline code, and monospace text, e.g. Geist Mono medium, monospace',
|
|
1357
|
+
'font.uiWeight': 'Interface weight',
|
|
1358
|
+
'font.codeWeight': 'Code weight',
|
|
1359
|
+
'font.weightShipped': ' (shipped, not overridden)',
|
|
1360
|
+
'font.suggestions': 'Font suggestions',
|
|
1361
|
+
'font.familyKind': 'family',
|
|
1362
|
+
'font.genericKind': 'generic',
|
|
1363
|
+
'font.pending': 'Press Enter to apply (your text is not rewritten)',
|
|
1364
|
+
'font.emptyQuery':
|
|
1365
|
+
'This query is empty: nothing is written, the saved setting is still in use — press Reset to defaults to go back to the shipped stack',
|
|
1366
|
+
'font.diag.unknownFamily':
|
|
1367
|
+
'No {name} in this machine\u2019s font list; it is still stored, but the browser may fall through to the next family',
|
|
1368
|
+
'font.diag.missingWeight':
|
|
1369
|
+
'{family} has no {weight} face on this machine ({word}), so the browser will synthesize it',
|
|
1370
|
+
'font.diag.duplicateWeight': 'A query carries one weight; the first wins and {word} is ignored',
|
|
1371
|
+
'font.diag.unclosedQuote':
|
|
1372
|
+
'The quote is never closed, so everything after it is read as the same family name',
|
|
1373
|
+
'font.diag.trailingText':
|
|
1374
|
+
'“{text}” follows the closing quote; it is neither a weight nor part of that name, so it is ignored',
|
|
1375
|
+
'font.weight.thin': 'Thin',
|
|
1376
|
+
'font.weight.extralight': 'ExtraLight',
|
|
1377
|
+
'font.weight.light': 'Light',
|
|
1378
|
+
'font.weight.regular': 'Regular',
|
|
1379
|
+
'font.weight.medium': 'Medium',
|
|
1380
|
+
'font.weight.semibold': 'SemiBold',
|
|
1381
|
+
'font.weight.bold': 'Bold',
|
|
1382
|
+
'font.weight.extrabold': 'ExtraBold',
|
|
1383
|
+
'font.weight.black': 'Black',
|
|
151
1384
|
'font.uiScale': 'Interface text size',
|
|
152
1385
|
'font.uiScaleHint': 'Scales every interface text size proportionally',
|
|
153
1386
|
'font.contentSize': 'Conversation text size',
|
|
@@ -156,9 +1389,13 @@ const en = {
|
|
|
156
1389
|
'font.codeSizeHint': 'Size of code blocks and inline code',
|
|
157
1390
|
'font.unit': 'px',
|
|
158
1391
|
'font.reset': 'Reset to defaults',
|
|
159
|
-
'font.presets': 'Presets',
|
|
160
1392
|
'font.increase': 'Increase',
|
|
161
1393
|
'font.decrease': 'Decrease',
|
|
1394
|
+
'font.add': 'Insert',
|
|
1395
|
+
'font.catalogProbed':
|
|
1396
|
+
'The font list holds only common fonts found by probing (reading this machine\u2019s fonts is unavailable or was declined); any family can still be typed',
|
|
1397
|
+
'font.genericWarning':
|
|
1398
|
+
'No generic family at the end (such as sans-serif), so a missing font may fall back unpredictably',
|
|
162
1399
|
}
|
|
163
1400
|
|
|
164
1401
|
/** The stylesheet this plugin owns, keyed by the resolved settings section. */
|
|
@@ -166,11 +1403,31 @@ function fontStyleSheet(section) {
|
|
|
166
1403
|
const scale = section[UI_FONT_SCALE_FIELD]
|
|
167
1404
|
const contentSize = section[CONTENT_FONT_SIZE_FIELD]
|
|
168
1405
|
const codeSize = section[CODE_FONT_SIZE_FIELD]
|
|
1406
|
+
const codeWeight = normalizeWeight(section[CODE_FONT_WEIGHT_FIELD])
|
|
1407
|
+
const uiWeight = normalizeWeight(section[UI_FONT_WEIGHT_FIELD])
|
|
169
1408
|
|
|
170
1409
|
const scaleRules = UI_TEXT_STEPS.map(
|
|
171
1410
|
(step) => `.dsh-font-size-${String(step)}{font-size:calc(${String(step)}px * var(--dsh-font-ui-scale,1)) !important}`,
|
|
172
1411
|
).join('')
|
|
173
1412
|
|
|
1413
|
+
// The interface weight is opt-in. At the shipped 400 nothing below changes,
|
|
1414
|
+
// which keeps a default install's sheet byte-identical to the shipped one; at
|
|
1415
|
+
// anything else the base of every `font:` shorthand has to be written out,
|
|
1416
|
+
// because a shorthand with no weight component resets `font-weight` to
|
|
1417
|
+
// `normal` and would silently defeat an inherited value.
|
|
1418
|
+
const shippedUiWeight = uiWeight === DEFAULT_UI_FONT_WEIGHT
|
|
1419
|
+
const baseWeight = shippedUiWeight ? '' : `${String(uiWeight)} `
|
|
1420
|
+
const emphasis = (shipped) => (shippedUiWeight ? shipped : emphasisWeight(shipped, uiWeight))
|
|
1421
|
+
const uiWeightRules = shippedUiWeight
|
|
1422
|
+
? []
|
|
1423
|
+
: [
|
|
1424
|
+
'/* The interface weight: the base of interface text. Only the text that',
|
|
1425
|
+
' inherits its weight moves — a label or a button whose weight the design',
|
|
1426
|
+
' system fixes keeps it — and the heading steps keep their shipped',
|
|
1427
|
+
' distance from the base so the hierarchy survives the change. */',
|
|
1428
|
+
`html body{font-weight:${String(uiWeight)}}`,
|
|
1429
|
+
]
|
|
1430
|
+
|
|
174
1431
|
return [
|
|
175
1432
|
'/* dsh-font: interface scale + conversation text sizes.',
|
|
176
1433
|
' The scale is emitted as one utility class per hard-coded UI text size and',
|
|
@@ -179,34 +1436,49 @@ function fontStyleSheet(section) {
|
|
|
179
1436
|
'html body{',
|
|
180
1437
|
`--dsh-font-ui-scale:${String(scale)};`,
|
|
181
1438
|
`--dsh-font-code-size:${String(codeSize)}px;`,
|
|
1439
|
+
`--dsh-font-code-weight:${String(codeWeight)};`,
|
|
182
1440
|
// A CSS-side mirror of the content size. The authoritative declaration is
|
|
183
1441
|
// inline on `body` (written by applyFonts), because ui-layout's theme
|
|
184
1442
|
// presenter owns that one and an inline value outranks any stylesheet.
|
|
185
1443
|
`--dsh-font-conversation-size:${String(contentSize)}px;`,
|
|
186
1444
|
'}',
|
|
1445
|
+
...uiWeightRules,
|
|
187
1446
|
scaleRules,
|
|
188
1447
|
'/* Conversation text sizes: absolute px, replacing the shipped 12..17px',
|
|
189
1448
|
' ladder that ui-layout drives from the `ui-theme` namespace. This is the',
|
|
190
1449
|
' size axis for conversation content; the scale above is the axis for the',
|
|
191
1450
|
' surrounding interface, and the two never compose. */',
|
|
192
1451
|
'html body{',
|
|
193
|
-
`--dsh-font-markdown-base
|
|
194
|
-
`--dsh-font-markdown-h1
|
|
195
|
-
`--dsh-font-markdown-h2
|
|
196
|
-
`--dsh-font-markdown-h3
|
|
197
|
-
`--dsh-font-markdown-h4
|
|
198
|
-
`--dsh-font-markdown-table
|
|
199
|
-
`--dsh-font-markdown-table-head
|
|
1452
|
+
`--dsh-font-markdown-base:${baseWeight}var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
|
|
1453
|
+
`--dsh-font-markdown-h1:${String(emphasis(700))} calc(${String(contentSize)}px + 7px) / calc(${String(contentSize)}px + 16px) var(--dsw-font-family) !important;`,
|
|
1454
|
+
`--dsh-font-markdown-h2:${String(emphasis(700))} calc(${String(contentSize)}px + 5px) / calc(${String(contentSize)}px + 14px) var(--dsw-font-family) !important;`,
|
|
1455
|
+
`--dsh-font-markdown-h3:${String(emphasis(700))} calc(${String(contentSize)}px + 4px) / calc(${String(contentSize)}px + 12px) var(--dsw-font-family) !important;`,
|
|
1456
|
+
`--dsh-font-markdown-h4:${String(emphasis(600))} var(--dsh-font-conversation-size,14px) / calc(${String(contentSize)}px + 10px) var(--dsw-font-family) !important;`,
|
|
1457
|
+
`--dsh-font-markdown-table:${baseWeight}calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
|
|
1458
|
+
`--dsh-font-markdown-table-head:${String(emphasis(500))} calc(${String(contentSize)}px - 1px) / calc(${String(contentSize)}px + 9px) var(--dsw-font-family) !important;`,
|
|
200
1459
|
'}',
|
|
201
1460
|
'/* Code text sizes, on their own axis. */',
|
|
202
1461
|
'html body{',
|
|
203
|
-
`--dsw-font-markdown-code:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 7px) var(--ds-font-family-code) !important;`,
|
|
1462
|
+
`--dsw-font-markdown-code:var(--dsh-font-code-weight,400) var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 7px) var(--ds-font-family-code) !important;`,
|
|
204
1463
|
`--dsw-font-markdown-code-font-size:var(--dsh-font-code-size,12px) !important;`,
|
|
205
|
-
`--dsw-font-markdown-code-block:var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 8px) var(--ds-font-family-code) !important;`,
|
|
1464
|
+
`--dsw-font-markdown-code-block:var(--dsh-font-code-weight,400) var(--dsh-font-code-size,12px) / calc(var(--dsh-font-code-size,12px) + 8px) var(--ds-font-family-code) !important;`,
|
|
206
1465
|
`--dsw-font-markdown-code-block-font-size:var(--dsh-font-code-size,12px) !important;`,
|
|
207
|
-
`--dsw-font-markdown-code-block-small:calc(var(--dsh-font-code-size,12px) - 1px) / calc(var(--dsh-font-code-size,12px) + 5px) var(--ds-font-family-code) !important;`,
|
|
1466
|
+
`--dsw-font-markdown-code-block-small:var(--dsh-font-code-weight,400) calc(var(--dsh-font-code-size,12px) - 1px) / calc(var(--dsh-font-code-size,12px) + 5px) var(--ds-font-family-code) !important;`,
|
|
208
1467
|
`--dsw-font-markdown-code-block-small-font-size:calc(var(--dsh-font-code-size,12px) - 1px) !important;`,
|
|
209
1468
|
'}',
|
|
1469
|
+
'/* The code weight, which the design system has no token for. The three',
|
|
1470
|
+
' `font:` shorthands above carry it for the surfaces that read a token,',
|
|
1471
|
+
' but most code in the interface is styled directly with',
|
|
1472
|
+
' `font-family: var(--ds-font-family-code)` in a component stylesheet with',
|
|
1473
|
+
' its own literal weight — the tool I/O cards, the terminal output, the',
|
|
1474
|
+
' diff and source previews. Those are matched structurally instead, and',
|
|
1475
|
+
' the `!important` is what outranks `font: 500 12px/18px …`.',
|
|
1476
|
+
' Note that this is deliberately NOT a universal rule: it reaches code the',
|
|
1477
|
+
' interface owns, never the surrounding labels. */',
|
|
1478
|
+
'html body pre,html body code,html body [class*="code" i]{',
|
|
1479
|
+
`font-family:var(--ds-font-family-code) !important;`,
|
|
1480
|
+
`font-weight:var(--dsh-font-code-weight,${String(DEFAULT_CODE_FONT_WEIGHT)}) !important;`,
|
|
1481
|
+
'}',
|
|
210
1482
|
].join('\n')
|
|
211
1483
|
}
|
|
212
1484
|
|
|
@@ -379,6 +1651,8 @@ function createFontRowStore() {
|
|
|
379
1651
|
init: () => ({
|
|
380
1652
|
uiFontFamily: DEFAULT_UI_FONT_FAMILY,
|
|
381
1653
|
codeFontFamily: DEFAULT_CODE_FONT_FAMILY,
|
|
1654
|
+
codeFontWeight: DEFAULT_CODE_FONT_WEIGHT,
|
|
1655
|
+
uiFontWeight: DEFAULT_UI_FONT_WEIGHT,
|
|
382
1656
|
uiFontScale: 1,
|
|
383
1657
|
contentFontSize: 14,
|
|
384
1658
|
codeFontSize: 12,
|
|
@@ -389,6 +1663,8 @@ function createFontRowStore() {
|
|
|
389
1663
|
if (revision !== undefined && revision <= draft.revision) return
|
|
390
1664
|
draft.uiFontFamily = section[UI_FONT_FAMILY_FIELD]
|
|
391
1665
|
draft.codeFontFamily = section[CODE_FONT_FAMILY_FIELD]
|
|
1666
|
+
draft.codeFontWeight = normalizeWeight(section[CODE_FONT_WEIGHT_FIELD])
|
|
1667
|
+
draft.uiFontWeight = normalizeWeight(section[UI_FONT_WEIGHT_FIELD])
|
|
392
1668
|
draft.uiFontScale = section[UI_FONT_SCALE_FIELD]
|
|
393
1669
|
draft.contentFontSize = section[CONTENT_FONT_SIZE_FIELD]
|
|
394
1670
|
draft.codeFontSize = section[CODE_FONT_SIZE_FIELD]
|
|
@@ -409,9 +1685,7 @@ const ROW_CSS = [
|
|
|
409
1685
|
'.dsh-font-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}',
|
|
410
1686
|
'.dsh-font-value{color:var(--dsw-alias-label-secondary);font-size:12px;font-variant-numeric:tabular-nums;line-height:18px}',
|
|
411
1687
|
'.dsh-font-hint{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}',
|
|
412
|
-
'.dsh-font-
|
|
413
|
-
'.dsh-font-input:focus{border-color:var(--dsw-alias-brand-primary)}',
|
|
414
|
-
'.dsh-font-code{font-family:var(--ds-font-family-code)}',
|
|
1688
|
+
'.dsh-font-code{font-family:var(--ds-font-family-code);font-weight:var(--dsh-font-code-weight,400)}',
|
|
415
1689
|
'.dsh-font-sliderRow{align-items:center;gap:10px;display:flex}',
|
|
416
1690
|
'.dsh-font-slider{flex:1;min-width:0;accent-color:var(--dsw-alias-brand-primary)}',
|
|
417
1691
|
'.dsh-font-stepper{background:var(--dsw-alias-bg-module-platform);border-radius:16px;justify-content:center;align-items:center;gap:2px;min-width:96px;height:32px;display:inline-flex;flex:none;padding:0 4px}',
|
|
@@ -419,12 +1693,55 @@ const ROW_CSS = [
|
|
|
419
1693
|
'.dsh-font-step:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
420
1694
|
'.dsh-font-step:disabled{color:var(--dsw-alias-label-caption);cursor:default}',
|
|
421
1695
|
'.dsh-font-stepValue{text-align:center;min-width:32px;color:var(--dsw-alias-label-primary);font-size:13px;font-variant-numeric:tabular-nums;line-height:20px}',
|
|
422
|
-
'.dsh-font-presets{flex-wrap:wrap;gap:6px;display:flex}',
|
|
423
|
-
'.dsh-font-chip{border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:12px;padding:3px 10px;font-family:inherit;font-size:11px;line-height:16px}',
|
|
424
|
-
'.dsh-font-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
425
|
-
'.dsh-font-chip[data-active="true"]{border-color:var(--dsw-static-neutral-bluish-400);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform)}',
|
|
426
1696
|
'.dsh-font-reset{align-self:flex-start;border:.5px solid var(--dsw-alias-border-l4);background:0 0;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:10px;padding:5px 12px;font-family:inherit;font-size:12px;line-height:18px}',
|
|
427
1697
|
'.dsh-font-reset:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
1698
|
+
// ── the query editor ──────────────────────────────────────────────────────
|
|
1699
|
+
//
|
|
1700
|
+
// The editor is a textarea with a painted layer behind it. The textarea owns
|
|
1701
|
+
// the text and the caret but renders it transparent; the layer renders the
|
|
1702
|
+
// same characters, split into coloured tokens.
|
|
1703
|
+
//
|
|
1704
|
+
// ONE FONT, AND IT IS NOT THE USER'S. A textarea cannot style a substring, so
|
|
1705
|
+
// the layer and the field can only stay aligned if every character comes from
|
|
1706
|
+
// the same face at the same weight — the whole box therefore uses one system
|
|
1707
|
+
// monospace at 400, whatever font the query names. The user's font would break
|
|
1708
|
+
// this twice over: a programming face with ligatures (`->` in Fira Code) draws
|
|
1709
|
+
// one glyph in the layer while the field draws two, and a weight the face does
|
|
1710
|
+
// not have is synthesized differently in each. Ligatures, kerning, and
|
|
1711
|
+
// stretching are switched off outright for the same reason.
|
|
1712
|
+
//
|
|
1713
|
+
// The layer is therefore only ever allowed colour, background, and
|
|
1714
|
+
// text-decoration: anything that changes a glyph's advance would desynchronize
|
|
1715
|
+
// the two.
|
|
1716
|
+
'.dsh-font-query{flex-direction:column;gap:4px;display:flex}',
|
|
1717
|
+
'.dsh-font-queryBox{position:relative;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-module-platform)}',
|
|
1718
|
+
'.dsh-font-queryBox:focus-within{border-color:var(--dsw-alias-brand-primary)}',
|
|
1719
|
+
'.dsh-font-queryLayer,.dsh-font-queryInput{box-sizing:border-box;width:100%;margin:0;padding:5px 10px;border:none;font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:13px;font-weight:400;font-style:normal;font-stretch:normal;font-variant-ligatures:none;font-kerning:none;font-feature-settings:"liga" 0,"calt" 0,"dlig" 0;line-height:20px;letter-spacing:normal;word-spacing:normal;text-transform:none;text-indent:0;tab-size:4;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}',
|
|
1720
|
+
'.dsh-font-queryLayer{position:absolute;inset:0;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary)}',
|
|
1721
|
+
'.dsh-font-queryInput{position:relative;display:block;min-height:32px;max-height:120px;resize:none;overflow-y:auto;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);outline:none}',
|
|
1722
|
+
'.dsh-font-queryInput::placeholder{color:var(--dsw-alias-label-tertiary)}',
|
|
1723
|
+
'.dsh-font-queryInput::selection{background:var(--dsw-alias-interactive-bg-active)}',
|
|
1724
|
+
'.dsh-font-qFamily{color:var(--dsw-alias-label-primary)}',
|
|
1725
|
+
// The family that is in effect: the first one the browser can actually use.
|
|
1726
|
+
'.dsh-font-qEffective{background:var(--dsw-alias-markdown-inline-code);border-radius:3px}',
|
|
1727
|
+
// Keywords, not state: a generic family and a weight word are syntax. The
|
|
1728
|
+
// accent is the link colour because `--dsw-alias-brand-primary` resolves to
|
|
1729
|
+
// the ordinary text colour in both themes, which would colour nothing.
|
|
1730
|
+
'.dsh-font-qGeneric,.dsh-font-qWeight{color:var(--dsw-alias-link)}',
|
|
1731
|
+
'.dsh-font-qUnknown{color:var(--dsw-alias-state-warn-primary)}',
|
|
1732
|
+
'.dsh-font-qWeightMissing{color:var(--dsw-alias-state-warn-primary);text-decoration:underline wavy var(--dsw-alias-state-warn-primary)}',
|
|
1733
|
+
'.dsh-font-qComma{color:var(--dsw-alias-label-caption)}',
|
|
1734
|
+
'.dsh-font-menu{position:absolute;z-index:20;left:0;right:0;top:calc(100% + 4px);max-height:240px;overflow-y:auto;margin:0;padding:4px;list-style:none;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l2);border-radius:10px;box-shadow:var(--dsw-elevation-panel)}',
|
|
1735
|
+
'.dsh-font-option{cursor:pointer;border-radius:6px;padding:5px 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:8px}',
|
|
1736
|
+
'.dsh-font-optionActive{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
1737
|
+
'.dsh-font-optionKey{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}',
|
|
1738
|
+
'.dsh-font-optionDetail{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
1739
|
+
'.dsh-font-optionCustom{color:var(--dsw-alias-label-secondary);border-top:.5px solid var(--dsw-alias-border-l2);border-radius:0 0 6px 6px;display:block}',
|
|
1740
|
+
'.dsh-font-meta{flex-wrap:wrap;gap:8px;justify-content:space-between;display:flex}',
|
|
1741
|
+
'.dsh-font-warn{color:var(--dsw-alias-state-warn-primary);font-size:11px;line-height:16px}',
|
|
1742
|
+
// Text the reader could not parse: a red line, not a suggestion.
|
|
1743
|
+
'.dsh-font-warn.dsh-font-error{color:var(--dsw-alias-state-error-primary)}',
|
|
1744
|
+
'.dsh-font-pending{color:var(--dsw-alias-link);font-size:11px;line-height:16px}',
|
|
428
1745
|
].join('')
|
|
429
1746
|
|
|
430
1747
|
/** Install the row chrome stylesheet for the plugin's lifetime. */
|
|
@@ -466,50 +1783,437 @@ function Field({ label, value, hint, children }) {
|
|
|
466
1783
|
)
|
|
467
1784
|
}
|
|
468
1785
|
|
|
1786
|
+
/** The class one highlight token is painted with. */
|
|
1787
|
+
const QUERY_TOKEN_CLASS = {
|
|
1788
|
+
family: 'dsh-font-qFamily',
|
|
1789
|
+
generic: 'dsh-font-qGeneric',
|
|
1790
|
+
unknown: 'dsh-font-qUnknown',
|
|
1791
|
+
weight: 'dsh-font-qWeight',
|
|
1792
|
+
weightMissing: 'dsh-font-qWeightMissing',
|
|
1793
|
+
comma: 'dsh-font-qComma',
|
|
1794
|
+
space: '',
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* The class for one token, plus the pill that marks the family in effect.
|
|
1799
|
+
*
|
|
1800
|
+
* Only colour, background, and text-decoration may ever appear here: the layer
|
|
1801
|
+
* this paints shares a box with the real textarea, so any property that changes
|
|
1802
|
+
* a glyph's advance would slide every colour off its character.
|
|
1803
|
+
* @param token - one token from {@link fontQueryTokens}.
|
|
1804
|
+
* @param effectiveFamily - the lowercase name of the family in effect.
|
|
1805
|
+
* @returns the class attribute value.
|
|
1806
|
+
*/
|
|
1807
|
+
function queryTokenClass(token, effectiveFamily) {
|
|
1808
|
+
const base = QUERY_TOKEN_CLASS[token.kind] ?? ''
|
|
1809
|
+
if (token.family === undefined || effectiveFamily === undefined) return base
|
|
1810
|
+
return token.family.toLowerCase() === effectiveFamily
|
|
1811
|
+
? `${base} dsh-font-qEffective`.trim()
|
|
1812
|
+
: base
|
|
1813
|
+
}
|
|
1814
|
+
|
|
469
1815
|
/**
|
|
470
|
-
*
|
|
471
|
-
*
|
|
1816
|
+
* The font-query editor: a textarea over a painted layer, with autocomplete.
|
|
1817
|
+
*
|
|
1818
|
+
* Why a textarea and not `contenteditable`: the query is a *string* the user is
|
|
1819
|
+
* editing, so the browser's own text editing — selection, undo, IME, soft wrap
|
|
1820
|
+
* — is exactly what is wanted, and a painted layer behind a transparent
|
|
1821
|
+
* textarea reproduces it with syntax colours. `contenteditable` would mean
|
|
1822
|
+
* re-implementing all of that on top of a DOM that fights back.
|
|
1823
|
+
*
|
|
1824
|
+
* THE RULE THIS COMPONENT FOLLOWS: the text belongs to the user.
|
|
1825
|
+
*
|
|
1826
|
+
* It is never rewritten — not quoted, not reordered, not tidied, not refilled
|
|
1827
|
+
* when it is empty. Applying a query (Enter, or leaving the field) parses it and
|
|
1828
|
+
* writes the settings it names; it does not touch a character. Anything wrong
|
|
1829
|
+
* with the query is marked under the field and left alone. The only exception is
|
|
1830
|
+
* a completion the user picks from the list, which is an explicit request for
|
|
1831
|
+
* that replacement, and the only way a value from outside takes the text over is
|
|
1832
|
+
* a change that means something different from what is on screen (Reset, another
|
|
1833
|
+
* tab) while the field is not focused.
|
|
1834
|
+
*
|
|
472
1835
|
* @param props - React props.
|
|
473
|
-
* @returns the
|
|
1836
|
+
* @returns the editor element.
|
|
474
1837
|
*/
|
|
475
|
-
function
|
|
476
|
-
|
|
477
|
-
|
|
1838
|
+
function FontQueryEditor({
|
|
1839
|
+
value,
|
|
1840
|
+
weight,
|
|
1841
|
+
catalogue,
|
|
1842
|
+
styles,
|
|
1843
|
+
enumerated,
|
|
1844
|
+
monospace,
|
|
1845
|
+
label,
|
|
1846
|
+
labels,
|
|
1847
|
+
onFamilies,
|
|
1848
|
+
onWeight,
|
|
1849
|
+
}) {
|
|
1850
|
+
/**
|
|
1851
|
+
* The axis's shipped weight. A query at this weight carries no word: the
|
|
1852
|
+
* shipped value is not something the user chose, and spelling it out put a
|
|
1853
|
+
* `regular` after every interface font that nobody asked for. What applies is
|
|
1854
|
+
* still always stated in the line under the field.
|
|
1855
|
+
*/
|
|
1856
|
+
const shippedWeight = monospace === true ? DEFAULT_CODE_FONT_WEIGHT : DEFAULT_UI_FONT_WEIGHT
|
|
1857
|
+
const options = { catalogue, styles, enumerated }
|
|
1858
|
+
/** Families plus a weight, as the text the field edits. */
|
|
1859
|
+
const asQuery = (families, weight) =>
|
|
1860
|
+
serializeFontQuery(families, weight === shippedWeight ? undefined : weight)
|
|
1861
|
+
// The stored value rendered as the field's text. A weight word left inside the
|
|
1862
|
+
// family string by a hand edit belongs to the weight field, so the two stored
|
|
1863
|
+
// values are shown as one query rather than the raw string.
|
|
1864
|
+
const stored = parseFontQuery(value, options)
|
|
1865
|
+
const storedFamilies = stored.families.length > 0 ? stored.families : parseFamilyList(value)
|
|
1866
|
+
const storedText = asQuery(storedFamilies, weight)
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* THE EDITING RULE: the text is the user's.
|
|
1870
|
+
*
|
|
1871
|
+
* Nothing here ever rewrites what was typed. `draft` holds it verbatim, and it
|
|
1872
|
+
* is replaced only by a value that arrives from outside (Reset, another tab) —
|
|
1873
|
+
* or by a completion the user explicitly picked. Applying a query parses it and
|
|
1874
|
+
* writes the two settings fields; it does not touch the text. A query that
|
|
1875
|
+
* resolves to nothing is an unfinished edit and writes nothing at all, rather
|
|
1876
|
+
* than being "helpfully" replaced by the shipped stack: silently refilling a
|
|
1877
|
+
* box the user just cleared is indistinguishable from a bug.
|
|
1878
|
+
*
|
|
1879
|
+
* `undefined` means "nothing typed yet — show the stored value".
|
|
1880
|
+
*/
|
|
1881
|
+
const [draft, setDraft] = useState(undefined)
|
|
1882
|
+
const [open, setOpen] = useState(false)
|
|
1883
|
+
const [active, setActive] = useState(0)
|
|
1884
|
+
const [caret, setCaret] = useState(0)
|
|
1885
|
+
const inputRef = useRef(null)
|
|
1886
|
+
const layerRef = useRef(null)
|
|
1887
|
+
const focused = useRef(false)
|
|
1888
|
+
/** A caret to restore after the next render, for edits made without a mouse. */
|
|
1889
|
+
const pendingCaret = useRef(undefined)
|
|
1890
|
+
|
|
1891
|
+
const text = draft === undefined ? storedText : draft
|
|
1892
|
+
const position = Math.min(Math.max(caret, 0), text.length)
|
|
1893
|
+
const context = queryContextAt(text, position)
|
|
1894
|
+
const suggestion = querySuggestions(context, { ...options, shippedWeight })
|
|
1895
|
+
const parsed = parseFontQuery(text, options)
|
|
1896
|
+
const tokens = fontQueryTokens(text, options)
|
|
1897
|
+
/** The custom row is a completion too, so it takes part in keyboard travel. */
|
|
1898
|
+
const rows =
|
|
1899
|
+
suggestion.custom === undefined
|
|
1900
|
+
? suggestion.items
|
|
1901
|
+
: [...suggestion.items, { id: 'custom', kind: 'custom', insert: suggestion.custom }]
|
|
1902
|
+
const highlighted = clampHighlight(rows, active)
|
|
1903
|
+
/** What the axis is set to — the setting, not the text being typed. */
|
|
1904
|
+
const appliedWeight = normalizeWeight(weight)
|
|
1905
|
+
/** A query that says nothing about a family is an unfinished edit. */
|
|
1906
|
+
const unfilled = parsed.families.length === 0 && parsed.weight === undefined
|
|
1907
|
+
// The text carries something the setting does not (yet). Compared by MEANING,
|
|
1908
|
+
// so lowercase or unquoted input is not reported as "not applied".
|
|
1909
|
+
const pendingText =
|
|
1910
|
+
draft === undefined ? undefined : asQuery(parsed.families, parsed.weight ?? weight)
|
|
1911
|
+
const pending = pendingText !== undefined && pendingText !== storedText
|
|
1912
|
+
const effectiveFamily =
|
|
1913
|
+
parsed.effective >= 0 ? parsed.families[parsed.effective].toLowerCase() : undefined
|
|
1914
|
+
const typed = context.inner.trim()
|
|
478
1915
|
|
|
1916
|
+
const messages = parsed.diagnostics
|
|
1917
|
+
.map((diagnostic) => ({
|
|
1918
|
+
text: describeDiagnostic(diagnostic, labels),
|
|
1919
|
+
kind: diagnosticKind(diagnostic.code),
|
|
1920
|
+
}))
|
|
1921
|
+
.filter((message) => message.text !== '')
|
|
1922
|
+
if (parsed.families.length > 0 && !parsed.families.some((f) => isGenericFamilyName(f.toLowerCase()))) {
|
|
1923
|
+
messages.push({ text: labels.genericWarning, kind: 'warn' })
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// The auto-height and the caret both belong to the render *after* the one that
|
|
1927
|
+
// changed the text: a controlled textarea cannot be given a selection range in
|
|
1928
|
+
// the same tick as the value it refers to.
|
|
479
1929
|
useEffect(() => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
1930
|
+
const node = inputRef.current
|
|
1931
|
+
if (node === null || node === undefined) return
|
|
1932
|
+
node.style.height = 'auto'
|
|
1933
|
+
node.style.height = `${String(Math.min(node.scrollHeight, 120))}px`
|
|
1934
|
+
const wanted = pendingCaret.current
|
|
1935
|
+
if (wanted === undefined) return
|
|
1936
|
+
pendingCaret.current = undefined
|
|
1937
|
+
const clamped = Math.min(wanted, node.value.length)
|
|
1938
|
+
node.focus()
|
|
1939
|
+
node.setSelectionRange(clamped, clamped)
|
|
1940
|
+
setCaret(clamped)
|
|
1941
|
+
}, [text])
|
|
1942
|
+
|
|
1943
|
+
// A value that arrived from elsewhere — the Reset button, another tab — takes
|
|
1944
|
+
// the field over. Our own write coming back does not: it means the same thing
|
|
1945
|
+
// as the text on screen, and replacing the text then would be the plugin
|
|
1946
|
+
// rewriting the user's typing. Nor does anything interrupt a focused field.
|
|
1947
|
+
useEffect(() => {
|
|
1948
|
+
if (focused.current) return
|
|
1949
|
+
setDraft((current) => {
|
|
1950
|
+
if (current === undefined) return undefined
|
|
1951
|
+
const read = parseFontQuery(current, options)
|
|
1952
|
+
return asQuery(read.families, read.weight ?? weight) === storedText ? current : undefined
|
|
1953
|
+
})
|
|
1954
|
+
}, [storedText])
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Apply a query: parse it and write the settings fields it names.
|
|
1958
|
+
*
|
|
1959
|
+
* The text is NOT touched — not quoted, not reordered, not tidied. A query
|
|
1960
|
+
* that names no family is an unfinished edit and writes nothing, leaving the
|
|
1961
|
+
* stored stack alone; a query that names a weight but no family still moves
|
|
1962
|
+
* that axis, because a weight alone is a complete statement about it.
|
|
1963
|
+
* @param raw - the text to apply.
|
|
1964
|
+
*/
|
|
1965
|
+
const apply = (raw) => {
|
|
1966
|
+
const read = parseFontQuery(raw, options)
|
|
1967
|
+
if (read.families.length > 0) onFamilies(serializeFamilyList(read.families))
|
|
1968
|
+
if (read.weight !== undefined) onWeight(read.weight)
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Take one completion the user picked. This is the one place text is replaced
|
|
1973
|
+
* without the user having typed the replacement, and it only ever runs from an
|
|
1974
|
+
* explicit pick: a click on a row, or Tab on the highlighted one.
|
|
1975
|
+
* @param row - the chosen suggestion row.
|
|
1976
|
+
*/
|
|
1977
|
+
const accept = (row) => {
|
|
1978
|
+
const next = applySuggestion(text, suggestion, row.insert, row.kind)
|
|
1979
|
+
pendingCaret.current = next.caret
|
|
1980
|
+
setDraft(next.text)
|
|
1981
|
+
setCaret(next.caret)
|
|
1982
|
+
setOpen(false)
|
|
1983
|
+
setActive(0)
|
|
1984
|
+
// A weight row is an instruction about that axis value, not just text: the
|
|
1985
|
+
// shipped weight is implicit in the text, so the number has to travel with
|
|
1986
|
+
// the row rather than be re-parsed out of the insertion.
|
|
1987
|
+
const read = parseFontQuery(next.text, options)
|
|
1988
|
+
if (read.families.length > 0) onFamilies(serializeFamilyList(read.families))
|
|
1989
|
+
const nextWeight = row.weight ?? read.weight
|
|
1990
|
+
if (nextWeight !== undefined) onWeight(nextWeight)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** Move the caret's entry, which is the keyboard's reorder. */
|
|
1994
|
+
const move = (delta) => {
|
|
1995
|
+
const next = moveFontQueryEntry(text, position, delta)
|
|
1996
|
+
if (next.text === text) return
|
|
1997
|
+
pendingCaret.current = next.caret
|
|
1998
|
+
setDraft(next.text)
|
|
1999
|
+
setCaret(next.caret)
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const trackCaret = (event) => {
|
|
2003
|
+
const node = event.target
|
|
2004
|
+
if (node !== null && node !== undefined && typeof node.selectionStart === 'number') {
|
|
2005
|
+
setCaret(node.selectionStart)
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const onKeyDown = (event) => {
|
|
2010
|
+
if (event.altKey === true && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
2011
|
+
event.preventDefault()
|
|
2012
|
+
move(event.key === 'ArrowUp' ? -1 : 1)
|
|
488
2013
|
return
|
|
489
2014
|
}
|
|
490
|
-
if (
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
spellCheck: false,
|
|
496
|
-
className: monospace === true ? 'dsh-font-input dsh-font-code' : 'dsh-font-input',
|
|
497
|
-
value: draft,
|
|
498
|
-
placeholder,
|
|
499
|
-
onChange: (event) => {
|
|
500
|
-
editing.current = true
|
|
501
|
-
setDraft(event.target.value)
|
|
502
|
-
},
|
|
503
|
-
onBlur: commit,
|
|
504
|
-
onKeyDown: (event) => {
|
|
505
|
-
if (event.key === 'Enter') event.currentTarget.blur()
|
|
506
|
-
if (event.key === 'Escape') {
|
|
507
|
-
editing.current = false
|
|
508
|
-
setDraft(value)
|
|
509
|
-
event.currentTarget.blur()
|
|
2015
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
2016
|
+
event.preventDefault()
|
|
2017
|
+
if (!open) {
|
|
2018
|
+
setOpen(true)
|
|
2019
|
+
return
|
|
510
2020
|
}
|
|
511
|
-
|
|
512
|
-
|
|
2021
|
+
setActive((current) => {
|
|
2022
|
+
const last = Math.max(rows.length - 1, 0)
|
|
2023
|
+
return event.key === 'ArrowDown'
|
|
2024
|
+
? Math.min(current + 1, last)
|
|
2025
|
+
: Math.max(current - 1, 0)
|
|
2026
|
+
})
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
if (event.key === 'Enter') {
|
|
2030
|
+
event.preventDefault()
|
|
2031
|
+
// Enter APPLIES, it never completes: transforming what was typed because
|
|
2032
|
+
// the user pressed Enter is the rudest thing this control could do. Taking
|
|
2033
|
+
// a suggestion is Tab (or a click), which are unambiguous requests for it.
|
|
2034
|
+
setOpen(false)
|
|
2035
|
+
apply(text)
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
if (event.key === 'Tab' && open && highlighted >= 0 && typed !== '') {
|
|
2039
|
+
event.preventDefault()
|
|
2040
|
+
accept(rows[highlighted])
|
|
2041
|
+
return
|
|
2042
|
+
}
|
|
2043
|
+
if (event.key === 'Escape') {
|
|
2044
|
+
if (open) setOpen(false)
|
|
2045
|
+
// The one way back to the stored value: the user asks for their own text
|
|
2046
|
+
// to be discarded.
|
|
2047
|
+
else setDraft(undefined)
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
return React.createElement(
|
|
2052
|
+
'div',
|
|
2053
|
+
{ className: 'dsh-font-query' },
|
|
2054
|
+
React.createElement(
|
|
2055
|
+
'div',
|
|
2056
|
+
{ className: 'dsh-font-queryBox' },
|
|
2057
|
+
React.createElement(
|
|
2058
|
+
'div',
|
|
2059
|
+
{ className: 'dsh-font-queryLayer', ref: layerRef, 'aria-hidden': 'true' },
|
|
2060
|
+
tokens.map((token, index) =>
|
|
2061
|
+
React.createElement(
|
|
2062
|
+
'span',
|
|
2063
|
+
{ key: `${token.kind}-${String(index)}`, className: queryTokenClass(token, effectiveFamily) },
|
|
2064
|
+
token.text,
|
|
2065
|
+
),
|
|
2066
|
+
),
|
|
2067
|
+
// A zero-width space keeps the layer's last line box as tall as the
|
|
2068
|
+
// textarea's: a trailing newline would otherwise collapse in the layer
|
|
2069
|
+
// alone, and the box would jump as soon as one is typed.
|
|
2070
|
+
'\u200b',
|
|
2071
|
+
),
|
|
2072
|
+
React.createElement('textarea', {
|
|
2073
|
+
ref: inputRef,
|
|
2074
|
+
className: 'dsh-font-queryInput',
|
|
2075
|
+
value: text,
|
|
2076
|
+
rows: 1,
|
|
2077
|
+
spellCheck: false,
|
|
2078
|
+
autoComplete: 'off',
|
|
2079
|
+
autoCorrect: 'off',
|
|
2080
|
+
autoCapitalize: 'off',
|
|
2081
|
+
// No placeholder: a ghost of the shipped stack in an empty box reads as
|
|
2082
|
+
// a value the plugin put there. The hint above the field is the example.
|
|
2083
|
+
role: 'combobox',
|
|
2084
|
+
'aria-label': label,
|
|
2085
|
+
'aria-expanded': open && rows.length > 0,
|
|
2086
|
+
'aria-autocomplete': 'list',
|
|
2087
|
+
onFocus: (event) => {
|
|
2088
|
+
focused.current = true
|
|
2089
|
+
setOpen(true)
|
|
2090
|
+
trackCaret(event)
|
|
2091
|
+
},
|
|
2092
|
+
onBlur: () => {
|
|
2093
|
+
focused.current = false
|
|
2094
|
+
setOpen(false)
|
|
2095
|
+
// A caret restored after this would pull focus straight back.
|
|
2096
|
+
pendingCaret.current = undefined
|
|
2097
|
+
// An untouched field writes nothing: the stored value is already what
|
|
2098
|
+
// the settings hold, and a blur is not an edit.
|
|
2099
|
+
if (draft !== undefined) apply(text)
|
|
2100
|
+
},
|
|
2101
|
+
onChange: (event) => {
|
|
2102
|
+
const node = event.target
|
|
2103
|
+
const raw = node.value
|
|
2104
|
+
// Typed text goes in EXACTLY as typed. No comma is inserted, no quote
|
|
2105
|
+
// is added, nothing is reordered: a control that edits your keystrokes
|
|
2106
|
+
// is broken even when its guess would have been right.
|
|
2107
|
+
setDraft(raw)
|
|
2108
|
+
setCaret(typeof node.selectionStart === 'number' ? node.selectionStart : raw.length)
|
|
2109
|
+
setOpen(true)
|
|
2110
|
+
setActive(0)
|
|
2111
|
+
},
|
|
2112
|
+
onKeyDown,
|
|
2113
|
+
onKeyUp: trackCaret,
|
|
2114
|
+
onClick: trackCaret,
|
|
2115
|
+
onSelect: trackCaret,
|
|
2116
|
+
onScroll: (event) => {
|
|
2117
|
+
const layer = layerRef.current
|
|
2118
|
+
if (layer === null || layer === undefined) return
|
|
2119
|
+
layer.scrollTop = event.target.scrollTop
|
|
2120
|
+
layer.scrollLeft = event.target.scrollLeft
|
|
2121
|
+
},
|
|
2122
|
+
}),
|
|
2123
|
+
open && rows.length > 0
|
|
2124
|
+
? React.createElement(
|
|
2125
|
+
'ul',
|
|
2126
|
+
{ className: 'dsh-font-menu', role: 'listbox', 'aria-label': labels.list },
|
|
2127
|
+
rows.map((row, index) =>
|
|
2128
|
+
React.createElement(
|
|
2129
|
+
'li',
|
|
2130
|
+
{
|
|
2131
|
+
key: row.id,
|
|
2132
|
+
role: 'option',
|
|
2133
|
+
'aria-selected': index === highlighted,
|
|
2134
|
+
className: `dsh-font-option${index === highlighted ? ' dsh-font-optionActive' : ''}${row.kind === 'custom' ? ' dsh-font-optionCustom' : ''}`,
|
|
2135
|
+
// `onMouseDown` beats the textarea's blur, so a pick is not
|
|
2136
|
+
// lost to the commit that blur would otherwise run first.
|
|
2137
|
+
onMouseDown: (event) => {
|
|
2138
|
+
event.preventDefault()
|
|
2139
|
+
accept(row)
|
|
2140
|
+
},
|
|
2141
|
+
onMouseEnter: () => {
|
|
2142
|
+
setActive(index)
|
|
2143
|
+
},
|
|
2144
|
+
},
|
|
2145
|
+
row.kind === 'weight'
|
|
2146
|
+
? [
|
|
2147
|
+
React.createElement(
|
|
2148
|
+
'span',
|
|
2149
|
+
{ key: 'key', className: 'dsh-font-optionKey' },
|
|
2150
|
+
`${row.family} ${row.word}`,
|
|
2151
|
+
),
|
|
2152
|
+
React.createElement(
|
|
2153
|
+
'span',
|
|
2154
|
+
{ key: 'detail', className: 'dsh-font-optionDetail' },
|
|
2155
|
+
labels.weightName(row.weight),
|
|
2156
|
+
),
|
|
2157
|
+
]
|
|
2158
|
+
: row.kind === 'custom'
|
|
2159
|
+
? `${labels.add}: "${row.insert}"`
|
|
2160
|
+
: [
|
|
2161
|
+
React.createElement(
|
|
2162
|
+
'span',
|
|
2163
|
+
{ key: 'key', className: 'dsh-font-optionKey' },
|
|
2164
|
+
row.name,
|
|
2165
|
+
),
|
|
2166
|
+
React.createElement(
|
|
2167
|
+
'span',
|
|
2168
|
+
{ key: 'detail', className: 'dsh-font-optionDetail' },
|
|
2169
|
+
row.kind === 'generic' ? labels.generic : labels.font,
|
|
2170
|
+
),
|
|
2171
|
+
],
|
|
2172
|
+
),
|
|
2173
|
+
),
|
|
2174
|
+
)
|
|
2175
|
+
: null,
|
|
2176
|
+
),
|
|
2177
|
+
React.createElement(
|
|
2178
|
+
'div',
|
|
2179
|
+
{ className: 'dsh-font-meta' },
|
|
2180
|
+
React.createElement(
|
|
2181
|
+
'span',
|
|
2182
|
+
{ className: monospace === true ? 'dsh-font-hint dsh-font-code' : 'dsh-font-hint' },
|
|
2183
|
+
`font-family: ${serializeFamilyList(storedFamilies)}`,
|
|
2184
|
+
),
|
|
2185
|
+
),
|
|
2186
|
+
// The value the axis is SET to — not what the text parses to. Nothing here
|
|
2187
|
+
// pretends the typed query has been applied: that is what the line below is
|
|
2188
|
+
// for, and it is why clearing the field cannot change the readout.
|
|
2189
|
+
React.createElement(
|
|
2190
|
+
'div',
|
|
2191
|
+
{ className: 'dsh-font-hint' },
|
|
2192
|
+
`${labels.weightLine}: ${labels.weightName(appliedWeight)} ${String(appliedWeight)}${
|
|
2193
|
+
appliedWeight === shippedWeight ? labels.weightShipped : ''
|
|
2194
|
+
}`,
|
|
2195
|
+
),
|
|
2196
|
+
// An unfinished edit is reported, never "corrected": the stored stack is
|
|
2197
|
+
// still in use, and the text is left exactly as it was typed. There is
|
|
2198
|
+
// nothing to apply either, so the pending line stays out of it.
|
|
2199
|
+
unfilled && draft !== undefined
|
|
2200
|
+
? React.createElement('div', { className: 'dsh-font-hint' }, labels.emptyQuery)
|
|
2201
|
+
: null,
|
|
2202
|
+
pending && !unfilled
|
|
2203
|
+
? React.createElement('div', { className: 'dsh-font-pending' }, labels.pending)
|
|
2204
|
+
: null,
|
|
2205
|
+
messages.map((message, index) =>
|
|
2206
|
+
React.createElement(
|
|
2207
|
+
'div',
|
|
2208
|
+
{
|
|
2209
|
+
key: `${String(index)}`,
|
|
2210
|
+
className:
|
|
2211
|
+
message.kind === 'error' ? 'dsh-font-warn dsh-font-error' : 'dsh-font-warn',
|
|
2212
|
+
},
|
|
2213
|
+
message.text,
|
|
2214
|
+
),
|
|
2215
|
+
),
|
|
2216
|
+
)
|
|
513
2217
|
}
|
|
514
2218
|
|
|
515
2219
|
/**
|
|
@@ -568,44 +2272,73 @@ function SliderControl({ min, max, step, value, format, onChange, ariaLabel, inc
|
|
|
568
2272
|
}
|
|
569
2273
|
|
|
570
2274
|
/**
|
|
571
|
-
*
|
|
572
|
-
* @param props - React props.
|
|
573
|
-
* @returns the preset strip.
|
|
574
|
-
*/
|
|
575
|
-
function PresetStrip({ presets, current, onPick }) {
|
|
576
|
-
return React.createElement(
|
|
577
|
-
'div',
|
|
578
|
-
{ className: 'dsh-font-presets' },
|
|
579
|
-
presets.map((preset) =>
|
|
580
|
-
React.createElement(
|
|
581
|
-
'button',
|
|
582
|
-
{
|
|
583
|
-
key: preset.id,
|
|
584
|
-
type: 'button',
|
|
585
|
-
className: 'dsh-font-chip',
|
|
586
|
-
'data-active': preset.value === current ? 'true' : 'false',
|
|
587
|
-
onClick: () => {
|
|
588
|
-
onPick(preset.value)
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
preset.label,
|
|
592
|
-
),
|
|
593
|
-
),
|
|
594
|
-
)
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* The General-settings row: five controls over the `ui-font` namespace.
|
|
2275
|
+
* The General-settings row: the two font queries plus the four size axes.
|
|
599
2276
|
* @param props - composed slot props (`t`, `useStore`, and the inject actions).
|
|
600
2277
|
* @returns the row element tree.
|
|
601
2278
|
*/
|
|
602
2279
|
function FontRow({ t, useStore, setField, reset }) {
|
|
603
2280
|
const uiFontFamily = useStore((s) => s.uiFontFamily)
|
|
604
2281
|
const codeFontFamily = useStore((s) => s.codeFontFamily)
|
|
2282
|
+
const codeFontWeight = useStore((s) => s.codeFontWeight)
|
|
2283
|
+
const uiFontWeight = useStore((s) => s.uiFontWeight)
|
|
605
2284
|
const uiFontScale = useStore((s) => s.uiFontScale)
|
|
606
2285
|
const contentFontSize = useStore((s) => s.contentFontSize)
|
|
607
2286
|
const codeFontSize = useStore((s) => s.codeFontSize)
|
|
608
2287
|
|
|
2288
|
+
// Discovery runs once per session (the result is cached) and only when this
|
|
2289
|
+
// row is actually rendered, so the cost is never paid by a user who never
|
|
2290
|
+
// opens Settings.
|
|
2291
|
+
const [catalog, setCatalog] = useState({
|
|
2292
|
+
families: COMMON_FAMILIES,
|
|
2293
|
+
styles: {},
|
|
2294
|
+
enumerated: false,
|
|
2295
|
+
})
|
|
2296
|
+
useEffect(() => {
|
|
2297
|
+
let cancelled = false
|
|
2298
|
+
discoverFamilies()
|
|
2299
|
+
.then((result) => {
|
|
2300
|
+
if (!cancelled) setCatalog(result)
|
|
2301
|
+
})
|
|
2302
|
+
.catch(() => {
|
|
2303
|
+
/* the curated catalogue is already in state */
|
|
2304
|
+
})
|
|
2305
|
+
return () => {
|
|
2306
|
+
cancelled = true
|
|
2307
|
+
}
|
|
2308
|
+
}, [])
|
|
2309
|
+
|
|
2310
|
+
// The catalogue's provenance is worth one line, and only when it is BAD news:
|
|
2311
|
+
// an enumerated list is the machine's own and needs no announcing, while the
|
|
2312
|
+
// probe fallback is a short curated list the completion popup can never
|
|
2313
|
+
// complete — which the user would otherwise read as "my font is missing".
|
|
2314
|
+
// `measured` distinguishes a finished fallback from discovery still running.
|
|
2315
|
+
const catalogueNotice =
|
|
2316
|
+
catalog.measured === true && catalog.enumerated !== true ? t('font.catalogProbed') : undefined
|
|
2317
|
+
|
|
2318
|
+
/**
|
|
2319
|
+
* The editors' copy, resolved once per render so the components stay free of
|
|
2320
|
+
* the locale service and remain pure functions of their props.
|
|
2321
|
+
* @param weightLine - the axis's weight label (`代码字重` / `界面字重`).
|
|
2322
|
+
* @returns the label bag both {@link FontQueryEditor}s consume.
|
|
2323
|
+
*/
|
|
2324
|
+
const editorLabels = (weightLine) => ({
|
|
2325
|
+
list: t('font.suggestions'),
|
|
2326
|
+
add: t('font.add'),
|
|
2327
|
+
font: t('font.familyKind'),
|
|
2328
|
+
generic: t('font.genericKind'),
|
|
2329
|
+
weightLine,
|
|
2330
|
+
weightShipped: t('font.weightShipped'),
|
|
2331
|
+
pending: t('font.pending'),
|
|
2332
|
+
emptyQuery: t('font.emptyQuery'),
|
|
2333
|
+
genericWarning: t('font.genericWarning'),
|
|
2334
|
+
unknownFamily: t('font.diag.unknownFamily'),
|
|
2335
|
+
missingWeight: t('font.diag.missingWeight'),
|
|
2336
|
+
duplicateWeight: t('font.diag.duplicateWeight'),
|
|
2337
|
+
unclosedQuote: t('font.diag.unclosedQuote'),
|
|
2338
|
+
trailingText: t('font.diag.trailingText'),
|
|
2339
|
+
weightName: (weight) => t(`font.weight.${WEIGHT_KEYS[normalizeWeight(weight)]}`),
|
|
2340
|
+
})
|
|
2341
|
+
|
|
609
2342
|
return React.createElement(
|
|
610
2343
|
'div',
|
|
611
2344
|
{ className: 'dsh-font-row' },
|
|
@@ -614,41 +2347,52 @@ function FontRow({ t, useStore, setField, reset }) {
|
|
|
614
2347
|
{ className: 'dsh-font-head' },
|
|
615
2348
|
React.createElement('div', { className: 'dsh-font-title' }, t('font.title')),
|
|
616
2349
|
React.createElement('div', { className: 'dsh-font-desc' }, t('font.description')),
|
|
2350
|
+
catalogueNotice === undefined
|
|
2351
|
+
? null
|
|
2352
|
+
: React.createElement('div', { className: 'dsh-font-hint' }, catalogueNotice),
|
|
617
2353
|
),
|
|
618
2354
|
React.createElement(
|
|
619
2355
|
Field,
|
|
620
2356
|
{ label: t('font.uiFamily'), hint: t('font.uiFamilyHint') },
|
|
621
|
-
React.createElement(
|
|
2357
|
+
React.createElement(FontQueryEditor, {
|
|
622
2358
|
value: uiFontFamily,
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
2359
|
+
weight: normalizeWeight(uiFontWeight),
|
|
2360
|
+
catalogue: catalog.families,
|
|
2361
|
+
styles: catalog.styles,
|
|
2362
|
+
enumerated: catalog.enumerated,
|
|
2363
|
+
label: t('font.uiFamily'),
|
|
2364
|
+
labels: editorLabels(t('font.uiWeight')),
|
|
2365
|
+
// A commit that resolves to what is already stored writes nothing: the
|
|
2366
|
+
// settings document is durable, and a no-op round trip is still a write.
|
|
2367
|
+
onFamilies: (value) => {
|
|
2368
|
+
if (value !== uiFontFamily) setField(UI_FONT_FAMILY_FIELD, value)
|
|
626
2369
|
},
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
onPick: (value) => {
|
|
632
|
-
setField(UI_FONT_FAMILY_FIELD, value)
|
|
2370
|
+
onWeight: (weight) => {
|
|
2371
|
+
if (normalizeWeight(weight) !== normalizeWeight(uiFontWeight)) {
|
|
2372
|
+
setField(UI_FONT_WEIGHT_FIELD, weight)
|
|
2373
|
+
}
|
|
633
2374
|
},
|
|
634
2375
|
}),
|
|
635
2376
|
),
|
|
636
2377
|
React.createElement(
|
|
637
2378
|
Field,
|
|
638
2379
|
{ label: t('font.codeFamily'), hint: t('font.codeFamilyHint') },
|
|
639
|
-
React.createElement(
|
|
2380
|
+
React.createElement(FontQueryEditor, {
|
|
640
2381
|
value: codeFontFamily,
|
|
641
|
-
|
|
2382
|
+
weight: normalizeWeight(codeFontWeight),
|
|
2383
|
+
catalogue: catalog.families,
|
|
2384
|
+
styles: catalog.styles,
|
|
2385
|
+
enumerated: catalog.enumerated,
|
|
642
2386
|
monospace: true,
|
|
643
|
-
|
|
644
|
-
|
|
2387
|
+
label: t('font.codeFamily'),
|
|
2388
|
+
labels: editorLabels(t('font.codeWeight')),
|
|
2389
|
+
onFamilies: (value) => {
|
|
2390
|
+
if (value !== codeFontFamily) setField(CODE_FONT_FAMILY_FIELD, value)
|
|
645
2391
|
},
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
onPick: (value) => {
|
|
651
|
-
setField(CODE_FONT_FAMILY_FIELD, value)
|
|
2392
|
+
onWeight: (weight) => {
|
|
2393
|
+
if (normalizeWeight(weight) !== normalizeWeight(codeFontWeight)) {
|
|
2394
|
+
setField(CODE_FONT_WEIGHT_FIELD, weight)
|
|
2395
|
+
}
|
|
652
2396
|
},
|
|
653
2397
|
}),
|
|
654
2398
|
),
|
|
@@ -759,6 +2503,8 @@ export function apply(ctx) {
|
|
|
759
2503
|
return {
|
|
760
2504
|
[UI_FONT_FAMILY_FIELD]: text(UI_FONT_FAMILY_FIELD, DEFAULT_UI_FONT_FAMILY),
|
|
761
2505
|
[CODE_FONT_FAMILY_FIELD]: text(CODE_FONT_FAMILY_FIELD, DEFAULT_CODE_FONT_FAMILY),
|
|
2506
|
+
[CODE_FONT_WEIGHT_FIELD]: normalizeWeight(raw[CODE_FONT_WEIGHT_FIELD]),
|
|
2507
|
+
[UI_FONT_WEIGHT_FIELD]: normalizeWeight(raw[UI_FONT_WEIGHT_FIELD]),
|
|
762
2508
|
[UI_FONT_SCALE_FIELD]: number(UI_FONT_SCALE_FIELD, 1, UI_FONT_SCALE_MIN, UI_FONT_SCALE_MAX),
|
|
763
2509
|
[CONTENT_FONT_SIZE_FIELD]: Math.round(
|
|
764
2510
|
number(CONTENT_FONT_SIZE_FIELD, 14, CONTENT_FONT_SIZE_MIN, CONTENT_FONT_SIZE_MAX),
|
|
@@ -784,6 +2530,8 @@ export function apply(ctx) {
|
|
|
784
2530
|
const defaults = {
|
|
785
2531
|
[UI_FONT_FAMILY_FIELD]: DEFAULT_UI_FONT_FAMILY,
|
|
786
2532
|
[CODE_FONT_FAMILY_FIELD]: DEFAULT_CODE_FONT_FAMILY,
|
|
2533
|
+
[CODE_FONT_WEIGHT_FIELD]: DEFAULT_CODE_FONT_WEIGHT,
|
|
2534
|
+
[UI_FONT_WEIGHT_FIELD]: DEFAULT_UI_FONT_WEIGHT,
|
|
787
2535
|
[UI_FONT_SCALE_FIELD]: 1,
|
|
788
2536
|
[CONTENT_FONT_SIZE_FIELD]: 14,
|
|
789
2537
|
[CODE_FONT_SIZE_FIELD]: 12,
|