mirror of
https://github.com/be5invis/Iosevka.git
synced 2026-10-28 00:18:43 +00:00
1001 lines
27 KiB
TypeScript
1001 lines
27 KiB
TypeScript
import { javascript } from "@codemirror/lang-javascript";
|
|
import { python } from "@codemirror/lang-python";
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
import {
|
|
Decoration,
|
|
type DecorationSet,
|
|
EditorView,
|
|
MatchDecorator,
|
|
ViewPlugin,
|
|
type ViewUpdate,
|
|
} from "@codemirror/view";
|
|
import * as Toml from "@iarna/toml";
|
|
import CodeMirror from "@uiw/react-codemirror";
|
|
import { pascalCase } from "change-case";
|
|
import { produce } from "immer";
|
|
import Head from "next/head";
|
|
import { useRouter } from "next/router";
|
|
import type React from "react";
|
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
import { useLifecycles } from "react-use";
|
|
|
|
import {
|
|
ActiveVariantKind,
|
|
CharacterVariantPicker,
|
|
} from "../shared/components/character-variant-picker";
|
|
import { CheckGroup } from "../shared/components/check-group";
|
|
import {
|
|
ImportConfigurationPopup,
|
|
ImportConfigurationPopupEnableButton,
|
|
type PopupUxProps,
|
|
} from "../shared/components/customizer-import-popup";
|
|
import { EnumSelect } from "../shared/components/enum-select";
|
|
import { Section } from "../shared/components/section";
|
|
import { TokenizedCode } from "../shared/components/tokenized-code";
|
|
import {
|
|
type CustomizerProps,
|
|
defaultCustomizerProps,
|
|
formatBuildPlan,
|
|
resolveDisplayStyle,
|
|
} from "../shared/customizer";
|
|
import * as Cv from "../shared/data-import/cv";
|
|
import * as Gr from "../shared/data-import/grades";
|
|
import * as Ligation from "../shared/data-import/ligation";
|
|
import { LigationSamplerInnerImpl } from "../shared/index-parts/ligature-sampler";
|
|
import * as tokenizedSampleCode from "../shared/tokenized-sample-code";
|
|
import { buildHotCharRegex, featureAssignmentToCss } from "../shared/utils/feature-style";
|
|
import { joinCls } from "../shared/utils/join-classes";
|
|
import { Ptr, useStatePtr } from "../shared/utils/ptr";
|
|
import * as StyleHelper from "../shared/utils/style-helper";
|
|
|
|
enum TextEditorLanguage {
|
|
TypeScript = "TypeScript",
|
|
Python = "Python",
|
|
}
|
|
type CustomTextEditorSamples = Partial<Record<TextEditorLanguage, string>>;
|
|
|
|
type TextEditorControlProps = {
|
|
fontSize: number;
|
|
language: TextEditorLanguage;
|
|
onFontSizeChange: (fontSize: number) => void;
|
|
onLanguageChange: (language: TextEditorLanguage) => void;
|
|
};
|
|
type TextEditorProps = {
|
|
fontSize: number;
|
|
fontStyle: Gr.FontStyle;
|
|
hotChars: StyleHelper.Highlights;
|
|
language: TextEditorLanguage;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
};
|
|
|
|
const TEXT_EDITOR_SAMPLE_PATHS: Record<TextEditorLanguage, string> = {
|
|
[TextEditorLanguage.TypeScript]: "/customizer-code-samples/sample.ts",
|
|
[TextEditorLanguage.Python]: "/customizer-code-samples/sample.py",
|
|
};
|
|
const TEXT_EDITOR_DEFAULT_FONT_SIZE = 16;
|
|
const TEXT_EDITOR_MAX_HEIGHT = "24rem";
|
|
|
|
function trimTerminalNewline(text: string): string {
|
|
return text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
}
|
|
|
|
export default function Customizer() {
|
|
const pCC = useStatePtr(defaultCustomizerProps);
|
|
const pUx = useStatePtr(defaultUxProps);
|
|
useLifecycles(
|
|
() => {
|
|
setTimeout(() => {
|
|
pCC.set(StyleHelper.createCustomizerPropsFromUrl(pCC.val));
|
|
}, 0);
|
|
},
|
|
() => {},
|
|
);
|
|
|
|
return (
|
|
<CustomizerCtx.Provider value={pCC}>
|
|
<UxContext.Provider value={pUx}>
|
|
<div className="page">
|
|
<Head>
|
|
<title>Iosevka Customizer</title>
|
|
</Head>
|
|
<Section className="customizer">
|
|
<div className="config-panel introduction">
|
|
<h1>Iosevka Customizer</h1>
|
|
<CustomizerBanner />
|
|
</div>
|
|
</Section>
|
|
<Section className="customizer">
|
|
<div className="config-panel">
|
|
<CustomizerHeader index={1}>Basics</CustomizerHeader>
|
|
<BasicsPanel />
|
|
</div>
|
|
<div className="config-co-panel">
|
|
<BasicsPanel2 />
|
|
</div>
|
|
</Section>
|
|
<Section className="customizer">
|
|
<div className="config-panel">
|
|
<CustomizerHeader index={2}>Variants</CustomizerHeader>
|
|
<VariantsPanel />
|
|
</div>
|
|
<FloatingPreview />
|
|
</Section>
|
|
<Section className="customizer">
|
|
<div className="config-panel">
|
|
<CustomizerHeader index={3}>Ligations</CustomizerHeader>
|
|
<LigationPanel />
|
|
<LigationPreview />
|
|
</div>
|
|
</Section>
|
|
<Section className="customizer">
|
|
<div className="try-panel">
|
|
<CustomizerHeader index={4}>Try It</CustomizerHeader>
|
|
<TextEditor />
|
|
</div>
|
|
</Section>
|
|
<Section className="customizer">
|
|
<div className="result-panel">
|
|
<CustomizerHeader index={5}>Your Config</CustomizerHeader>
|
|
<TomlResultPanel />
|
|
<BuildInstructionsPanel />
|
|
</div>
|
|
</Section>
|
|
<ImportConfigurationPopup pCc={pCC} pUx={pUx} />
|
|
</div>
|
|
</UxContext.Provider>
|
|
</CustomizerCtx.Provider>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
// Customizer properties
|
|
|
|
const CustomizerCtx = createContext(Ptr.Default(defaultCustomizerProps));
|
|
|
|
// UX properties
|
|
type UxProps = PopupUxProps;
|
|
const defaultUxProps: UxProps = { popupVisible: false };
|
|
const UxContext = createContext(Ptr.Default(defaultUxProps));
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function CustomizerBanner() {
|
|
return (
|
|
<p>
|
|
Iosevka Customizer is a tool help creating{" "}
|
|
<a href="https://github.com/be5invis/Iosevka/blob/master/doc/custom-build.md">
|
|
Iosevka custom build configurations
|
|
</a>
|
|
.
|
|
</p>
|
|
);
|
|
}
|
|
|
|
type CustomizerHeaderProps = {
|
|
index: number;
|
|
children: string;
|
|
};
|
|
function CustomizerHeader(props: CustomizerHeaderProps) {
|
|
return (
|
|
<h2>
|
|
<em>{String(props.index).padStart(2, "0")}</em> {props.children}
|
|
</h2>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function BasicsPanel() {
|
|
const pCc = useContext(CustomizerCtx);
|
|
const pUx = useContext(UxContext);
|
|
|
|
const pSerifStyle = Ptr.Lens(pCc, "serifStyle");
|
|
|
|
return (
|
|
<div className="customizer-body">
|
|
<dl>
|
|
<dt>Family Name</dt>
|
|
<dd>
|
|
<input
|
|
type="text"
|
|
value={pCc.val.family}
|
|
onChange={x => Ptr.Lens(pCc, "family").set(x.target.value)}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt>Serifs</dt>
|
|
<dd>
|
|
<EnumSelect
|
|
options={Gr.DefaultSerifStyleGrades}
|
|
value={pSerifStyle.val}
|
|
onChange={pSerifStyle.set}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt>Spacing</dt>
|
|
<dd>
|
|
<EnumSelect
|
|
value={pCc.val.spacing}
|
|
options={Gr.SpacingGrades}
|
|
onChange={Ptr.Lens(pCc, "spacing").set}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt></dt>
|
|
<dd>
|
|
<ImportConfigurationPopupEnableButton {...pUx} />
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
function BasicsPanel2() {
|
|
const pCc = useContext(CustomizerCtx);
|
|
const { val: cc, set: setCC } = pCc;
|
|
|
|
const pfExportCvSs = Ptr.Lens(pCc, "fExportCvSs");
|
|
const pfExportGlyphNames = Ptr.Lens(pCc, "fExportGlyphNames");
|
|
return (
|
|
<div className="customizer-body">
|
|
<dl>
|
|
<dt>Weights</dt>
|
|
<GradeCheckBoxList
|
|
source={Gr.AllWeightGrades.keys()}
|
|
fEnabled={x => cc.weightGradesIncluded.has(x)}
|
|
formatter={WeightGradeFormatter}
|
|
fInteractive={x => x !== Gr.Weight.Regular}
|
|
sink={(a, c) => {
|
|
const s = new Set(cc.weightGradesIncluded);
|
|
if (c) s.delete(a);
|
|
else s.add(a);
|
|
setCC({ ...cc, weightGradesIncluded: s });
|
|
}}
|
|
/>
|
|
</dl>
|
|
<dl>
|
|
<dt>Widths</dt>
|
|
<GradeCheckBoxList
|
|
dummies={2}
|
|
source={Gr.AllWidthGrades.keys()}
|
|
fEnabled={x => cc.widthGradesIncluded.has(x)}
|
|
formatter={WidthGradeFormatter}
|
|
fInteractive={x =>
|
|
x !== (cc.defaultWidthAtExpanded ? Gr.Width.Expanded : Gr.Width.Normal)
|
|
}
|
|
sink={(a, c) => {
|
|
const s = new Set(cc.widthGradesIncluded);
|
|
if (c) s.delete(a);
|
|
else s.add(a);
|
|
setCC({ ...cc, widthGradesIncluded: s });
|
|
}}
|
|
/>
|
|
</dl>
|
|
<dl>
|
|
<dt>Slopes</dt>
|
|
<GradeCheckBoxList
|
|
source={Gr.AllSlopeGrades.keys()}
|
|
fEnabled={x => cc.slopeGradesIncluded.has(x)}
|
|
formatter={SlopeGradeFormatter}
|
|
fInteractive={x => x !== Gr.Slope.Upright}
|
|
sink={(a, c) => {
|
|
const s = new Set(cc.slopeGradesIncluded);
|
|
if (c) s.delete(a);
|
|
else s.add(a);
|
|
setCC({ ...cc, slopeGradesIncluded: s });
|
|
}}
|
|
/>
|
|
</dl>
|
|
<dl>
|
|
<dt></dt>
|
|
<dd>
|
|
<CheckGroup
|
|
label="Default width being 600"
|
|
value={cc.defaultWidthAtExpanded}
|
|
onChange={x => {
|
|
// Ensure the default width is included
|
|
const s = new Set(cc.widthGradesIncluded);
|
|
if (x) s.add(Gr.Width.Expanded);
|
|
else s.add(Gr.Width.Normal);
|
|
setCC({ ...cc, defaultWidthAtExpanded: x, widthGradesIncluded: s });
|
|
}}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt></dt>
|
|
<dd>
|
|
<CheckGroup
|
|
label="Export cv## / ss## OpenType features"
|
|
value={pfExportCvSs.val}
|
|
onChange={pfExportCvSs.set}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
<dl>
|
|
<dt></dt>
|
|
<dd>
|
|
<CheckGroup
|
|
label={
|
|
<>
|
|
Export glyph names for{" "}
|
|
<a href="https://sw.kovidgoyal.net/kitty/">Kitty</a>
|
|
</>
|
|
}
|
|
value={pfExportGlyphNames.val}
|
|
onChange={pfExportGlyphNames.set}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type GradesFormatter<E> = {
|
|
order(a: E, b: E): number;
|
|
identifier(value: E): string;
|
|
display(value: E): string;
|
|
titleT(value: E, i: boolean): string;
|
|
getFontStyle(values: E): Gr.FontStyle;
|
|
};
|
|
type GradeCheckBoxListProps<E> = {
|
|
dummies?: number;
|
|
className?: string;
|
|
source: Iterable<E>;
|
|
fEnabled: (item: E) => boolean;
|
|
sink: (item: E, currentlyEnabled: boolean) => void;
|
|
fInteractive: (item: E) => boolean;
|
|
formatter: GradesFormatter<E>;
|
|
};
|
|
function GradeCheckBoxList<E>(props: GradeCheckBoxListProps<E>) {
|
|
const a: React.ReactElement[] = [];
|
|
const sorted = Array.from(props.source).sort(props.formatter.order);
|
|
if (props.dummies) {
|
|
for (let i = 0; i < props.dummies; i++) {
|
|
a.push(<GradeCheckBoxDummy key={`dummy-${i}`} />);
|
|
}
|
|
}
|
|
for (const item of sorted) {
|
|
const interactive = props.fInteractive(item);
|
|
a.push(
|
|
<GradeCheckBox
|
|
{...props}
|
|
grade={item}
|
|
key={props.formatter.identifier(item)}
|
|
interactive={interactive}
|
|
title={props.formatter.titleT(item, interactive)}
|
|
/>,
|
|
);
|
|
}
|
|
return <dd className={joinCls("grade-list", props.className)}>{a}</dd>;
|
|
}
|
|
|
|
function GradeCheckBoxDummy() {
|
|
return <button disabled={true} className="thumb-check-box grade-button weight dummy" />;
|
|
}
|
|
|
|
type GradeCheckBoxProps<E> = GradeCheckBoxListProps<E> & {
|
|
grade: E;
|
|
interactive: boolean;
|
|
title?: string;
|
|
};
|
|
function GradeCheckBox<E>(props: GradeCheckBoxProps<E>) {
|
|
return (
|
|
<button
|
|
className={joinCls(
|
|
"thumb-check-box grade-button weight",
|
|
props.fEnabled(props.grade) ? "checked" : "unchecked",
|
|
props.interactive ? "interactive" : "non-interactive",
|
|
Gr.fontStyleToCls(props.formatter.getFontStyle(props.grade)),
|
|
)}
|
|
title={props.title}
|
|
onClick={e => {
|
|
if (props.interactive) props.sink(props.grade, props.fEnabled(props.grade));
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
>
|
|
<span className="label">{props.formatter.display(props.grade)}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const WeightGradeFormatter: GradesFormatter<Gr.Weight> = {
|
|
order: (a, b) => a - b,
|
|
identifier: a => `weight-${a}`,
|
|
display: a => String(a),
|
|
titleT: (a, i) => {
|
|
const d = Gr.AllWeightGrades.get(a)?.display;
|
|
return i
|
|
? `Click to toggle whether weight ${d} is included.`
|
|
: `Weight ${d} is always included and cannot be disabled.`;
|
|
},
|
|
getFontStyle: a => ({ ...Gr.DefaultFontStyle, weight: a }),
|
|
};
|
|
const WidthGradeFormatter: GradesFormatter<Gr.Width> = {
|
|
order: (a, b) => a - b,
|
|
identifier: a => `width-${a}`,
|
|
display: a => String(a),
|
|
titleT: (a, i) => {
|
|
const d = Gr.AllWidthGrades.get(a)?.display;
|
|
return i
|
|
? `Click to toggle whether width ${d} is included.`
|
|
: `Weight ${d} is always included and cannot be disabled.`;
|
|
},
|
|
getFontStyle: a => ({ ...Gr.DefaultFontStyle, width: a }),
|
|
};
|
|
const SlopeGradeFormatter: GradesFormatter<Gr.Slope> = {
|
|
order: (a, b) => a - b,
|
|
identifier: a => `slope-${a}`,
|
|
display: a => Gr.Slope[a],
|
|
titleT: (a, i) => {
|
|
const d = Gr.AllSlopeGrades.get(a)?.display;
|
|
return i
|
|
? `Click to toggle whether slope ${d} is included.`
|
|
: `Slope ${d} is always included and cannot be disabled.`;
|
|
},
|
|
getFontStyle: a => ({ ...Gr.DefaultFontStyle, slope: a }),
|
|
};
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function SinkInheritance(pCC: Ptr<CustomizerProps>, k: Cv.StylisticSet) {
|
|
pCC.set(
|
|
produce(cc => {
|
|
cc.charVariants.inherits = k === Cv.NopStylisticSet ? undefined : k;
|
|
}),
|
|
);
|
|
}
|
|
function CvSink(prime: Cv.Prime, into: StyleHelper.SlopeKey, pCC: Ptr<CustomizerProps>) {
|
|
return (k: string) =>
|
|
pCC.set(
|
|
produce(cc => {
|
|
if (!cc.charVariants[into]) cc.charVariants[into] = {};
|
|
cc.charVariants[into][prime.key] = k;
|
|
}),
|
|
);
|
|
}
|
|
function CvClearSink(prime: Cv.Prime, into: StyleHelper.SlopeKey, pCC: Ptr<CustomizerProps>) {
|
|
return () =>
|
|
pCC.set(
|
|
produce(cc => {
|
|
const cv = cc.charVariants[into] || {};
|
|
delete cv[prime.key];
|
|
cc.charVariants[into] = cv;
|
|
}),
|
|
);
|
|
}
|
|
function enableOverrideGroup(sk: StyleHelper.SlopeKey, pCC: Ptr<CustomizerProps>) {
|
|
pCC.set(
|
|
produce(cc => {
|
|
cc.charVariants[sk] = { ...cc.charVariants.design };
|
|
}),
|
|
);
|
|
}
|
|
function clearOverrideGroup(sk: StyleHelper.SlopeKey, pCC: Ptr<CustomizerProps>) {
|
|
pCC.set(
|
|
produce(cc => {
|
|
delete cc.charVariants[sk];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function VariantsPanel() {
|
|
const pCC = useContext(CustomizerCtx);
|
|
|
|
return (
|
|
<div className="customizer-body variants">
|
|
<VariantsInheritanceField />
|
|
<VariantGroup slopeKey="design" visible={true} />
|
|
|
|
<CheckGroup
|
|
label="Override Italics"
|
|
value={Boolean(pCC.val.charVariants.italic)}
|
|
onChange={x => {
|
|
if (x) enableOverrideGroup("italic", pCC);
|
|
else clearOverrideGroup("italic", pCC);
|
|
}}
|
|
/>
|
|
<VariantGroup slopeKey="italic" visible={Boolean(pCC.val.charVariants.italic)} />
|
|
|
|
<CheckGroup
|
|
label="Override Oblique"
|
|
value={Boolean(pCC.val.charVariants.oblique)}
|
|
onChange={x => {
|
|
if (x) enableOverrideGroup("oblique", pCC);
|
|
else clearOverrideGroup("oblique", pCC);
|
|
}}
|
|
/>
|
|
<VariantGroup slopeKey="oblique" visible={Boolean(pCC.val.charVariants.oblique)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VariantsInheritanceField() {
|
|
const pCC = useContext(CustomizerCtx);
|
|
|
|
return (
|
|
<dl>
|
|
<dt>Inherits</dt>
|
|
<dd>
|
|
<select
|
|
value={(pCC.val.charVariants.inherits || Cv.NopStylisticSet).key}
|
|
onChange={x =>
|
|
SinkInheritance(
|
|
pCC,
|
|
Cv.StylisticSets.get(x.target.value) || Cv.NopStylisticSet,
|
|
)
|
|
}
|
|
>
|
|
{Array.from(Cv.StylisticSets.values()).map(x => (
|
|
<option key={x.key} value={x.key}>
|
|
{x.description + (x.tag && x.rank ? ` (${x.tag})` : "")}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</dd>
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
type VariantGroupProps = { slopeKey: StyleHelper.SlopeKey; visible: boolean };
|
|
function VariantGroup(props: VariantGroupProps) {
|
|
const pCC = useContext(CustomizerCtx);
|
|
const cc = pCC.val;
|
|
|
|
if (!props.visible) return null;
|
|
|
|
const targetSlope =
|
|
props.slopeKey === "italic"
|
|
? Gr.Slope.Italic
|
|
: props.slopeKey === "oblique"
|
|
? Gr.Slope.Oblique
|
|
: Gr.Slope.Upright;
|
|
const resolved = StyleHelper.resolveUserChoice(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: targetSlope,
|
|
});
|
|
const fs: Gr.FontStyle = { style: resolveDisplayStyle(cc), slope: targetSlope };
|
|
return (
|
|
<div className="group">
|
|
{Array.from(Cv.CharacterVariants.values()).map(prime => (
|
|
<CharacterVariantPicker
|
|
className="large"
|
|
prime={prime}
|
|
key={prime.key}
|
|
fontStyle={fs}
|
|
onItemSelect={CvSink(prime, props.slopeKey, pCC)}
|
|
onItemClear={CvClearSink(prime, props.slopeKey, pCC)}
|
|
activeVariantKey={resolved.resolvedComposition[prime.key]}
|
|
activeVariantKind={
|
|
cc.charVariants[props.slopeKey] &&
|
|
prime.key in (cc.charVariants[props.slopeKey] || {})
|
|
? ActiveVariantKind.NonDefault
|
|
: ActiveVariantKind.Default
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function FloatingPreview() {
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const resolved = StyleHelper.resolveUserChoice(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: Gr.Slope.Upright,
|
|
});
|
|
const fs: Gr.FontStyle = {
|
|
style: resolveDisplayStyle(cc),
|
|
spacingTag: cc.spacing ? "NWID" : "",
|
|
width: cc.defaultWidthAtExpanded ? Gr.Width.Expanded : Gr.Width.Normal,
|
|
charVarTags: resolved.userFriendlyFeatureAssignment,
|
|
};
|
|
return (
|
|
<div className="preview-panel customizer-preview">
|
|
<div className="inner">
|
|
<HighlightsToggle />
|
|
<CharGridPreview fs={fs} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HighlightsToggle() {
|
|
const pCC = useContext(CustomizerCtx);
|
|
|
|
return (
|
|
<label className="highlight-toggle toggle-group">
|
|
<input
|
|
type="checkbox"
|
|
checked={pCC.val.fDisplayHighlight}
|
|
onChange={e => pCC.set({ ...pCC.val, fDisplayHighlight: e.target.checked })}
|
|
/>{" "}
|
|
<span className="check-mark"></span>
|
|
Highlight Customized Characters
|
|
</label>
|
|
);
|
|
}
|
|
|
|
type PreviewProps = {
|
|
fs: Gr.FontStyle;
|
|
};
|
|
|
|
const sampleAscii = ["✳", ...lchRangeToStr(0x21, 0x7e), "🮲🮳"];
|
|
const sampleSymbolRow = "← ↑ → ↓ ■ □ ▲ △ ◇ ◈ <= >= λ ß ≨ ∑".split(" ");
|
|
function CharGridPreview(props: PreviewProps) {
|
|
return (
|
|
<ol className="grid-preview">
|
|
<GridPreviewCodeRows {...props} slope={Gr.Slope.Upright} />
|
|
<GridPreviewCodeRows {...props} slope={Gr.Slope.Italic} />
|
|
<GridPreviewCodeRows {...props} slope={Gr.Slope.Oblique} />
|
|
<GridPreviewCharRows {...props} sample={sampleAscii} />
|
|
<GridPreviewCharRows {...props} sample={sampleSymbolRow} />
|
|
</ol>
|
|
);
|
|
}
|
|
function lchRangeToStr(lchFirst: number, lchLast: number) {
|
|
let s = "";
|
|
for (let lch = lchFirst; lch <= lchLast; lch++) {
|
|
s += String.fromCodePoint(lch);
|
|
}
|
|
return s;
|
|
}
|
|
type GridPreviewCharRowsProps = PreviewProps & {
|
|
sample: Iterable<string>;
|
|
};
|
|
function GridPreviewCharRows(props: GridPreviewCharRowsProps) {
|
|
const items: React.ReactNode[] = [];
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const resolved = StyleHelper.resolveUserChoice(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: Gr.Slope.Upright,
|
|
});
|
|
for (const s of props.sample) {
|
|
const f = resolved.hotChars.get(s);
|
|
// We apply the "matching" feature for each highlighted character
|
|
// since Chromium may have problem when many features utilizing
|
|
// GSUB alternate is applied.
|
|
const style = f ? { fontFeatureSettings: featureAssignmentToCss(f) } : {};
|
|
items.push(
|
|
<li
|
|
key={s}
|
|
className={joinCls("cell", "single-char", Gr.fontStyleToCls(props.fs))}
|
|
style={style}
|
|
>
|
|
{s}
|
|
</li>,
|
|
);
|
|
}
|
|
return <>{items}</>;
|
|
}
|
|
|
|
type GridPreviewCodeRowsProps = PreviewProps & {
|
|
slope: Gr.Slope;
|
|
};
|
|
function GridPreviewCodeRows(props: GridPreviewCodeRowsProps) {
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const resolved = StyleHelper.resolveUserChoice(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: props.slope,
|
|
});
|
|
const fs: Gr.FontStyle = {
|
|
style: resolveDisplayStyle(cc),
|
|
slope: props.slope,
|
|
spacingTag: cc.spacing ? "NWID" : "",
|
|
width: cc.defaultWidthAtExpanded ? Gr.Width.Expanded : Gr.Width.Normal,
|
|
};
|
|
return (
|
|
<li
|
|
className={joinCls(
|
|
"cell",
|
|
"code-sample",
|
|
cc.slopeGradesIncluded.has(props.slope) ? "included" : "excluded",
|
|
cc.fDisplayHighlight ? "display-highlight" : "ignore-highlight",
|
|
)}
|
|
>
|
|
<div className="header">{Gr.Slope[props.slope]}</div>
|
|
<TokenizedCode
|
|
fontStyle={fs}
|
|
code={tokenizedSampleCode.Alphabet}
|
|
highlightCharSet={resolved.stepHotChars}
|
|
/>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function LigationPanel() {
|
|
const pCC = useContext(CustomizerCtx);
|
|
const setLigSet = (val: string) => {
|
|
pCC.set(
|
|
produce(cc => {
|
|
for (const ls of Ligation.AvailableLigationSets) {
|
|
if (ls.selector === val) {
|
|
cc.ligationSet = ls;
|
|
return;
|
|
}
|
|
}
|
|
cc.ligationSet = Ligation.AvailableLigationSets[0];
|
|
}),
|
|
);
|
|
};
|
|
const ligationDisabled = pCC.val.spacing === Gr.Spacing.Fixed;
|
|
return (
|
|
<div className="customizer-body">
|
|
<select
|
|
disabled={ligationDisabled}
|
|
value={ligationDisabled ? ".disabled" : pCC.val.ligationSet.selector || ".disabled"}
|
|
onChange={e => setLigSet(e.target.value)}
|
|
>
|
|
{Ligation.AvailableLigationSets.map(ls => (
|
|
<option key={ls.selector || ".disabled"} value={ls.selector || ".disabled"}>
|
|
{ligationDisabled ? "Ligation Disabled in Fixed" : ls.desc || ls.brief}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function LigationPreview() {
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const resolved = StyleHelper.resolveUserChoice(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: Gr.Slope.Upright,
|
|
});
|
|
const fs: Gr.FontStyle = {
|
|
style: resolveDisplayStyle(cc),
|
|
spacingTag: cc.spacing ? "NWID" : "",
|
|
width: cc.defaultWidthAtExpanded ? Gr.Width.Expanded : Gr.Width.Normal,
|
|
charVarTags: resolved.userFriendlyFeatureAssignment,
|
|
};
|
|
return (
|
|
<div className="customizer-body ligation-sampler">
|
|
<LigationSamplerInnerImpl
|
|
fontStyle={fs}
|
|
currentLigationSet={
|
|
cc.spacing === Gr.Spacing.Fixed
|
|
? Ligation.AvailableLigationSets[0]
|
|
: cc.ligationSet
|
|
}
|
|
ligationCherry={Ligation.LigationCherryData}
|
|
ligationSamples={Ligation.LigationSamples}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function TextEditor() {
|
|
const { basePath } = useRouter();
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const [language, setLanguage] = useState<TextEditorLanguage>(TextEditorLanguage.TypeScript);
|
|
const [fontSize, setFontSize] = useState(TEXT_EDITOR_DEFAULT_FONT_SIZE);
|
|
const [samples, setSamples] = useState<CustomTextEditorSamples>({});
|
|
// `cc.CharVariants` would be reference equality checked
|
|
// here. This should be fine because `cc.charVariants`
|
|
// should only update in an immutable manner.
|
|
const hotChars = useMemo(
|
|
() =>
|
|
StyleHelper.deriveHotChars(cc.charVariants, {
|
|
isSlab: cc.serifStyle === Gr.SerifStyle.Slab,
|
|
slope: Gr.Slope.Upright,
|
|
}),
|
|
[cc.charVariants, cc.serifStyle],
|
|
);
|
|
const effectiveLigations =
|
|
cc.spacing === Gr.Spacing.Fixed ? Ligation.AvailableLigationSets[0] : cc.ligationSet;
|
|
const fs: Gr.FontStyle = {
|
|
style: resolveDisplayStyle(cc),
|
|
spacingTag: cc.spacing ? "NWID" : "",
|
|
width: cc.defaultWidthAtExpanded ? Gr.Width.Expanded : Gr.Width.Normal,
|
|
ligationTag: effectiveLigations.tag,
|
|
ligationRank: effectiveLigations.rank,
|
|
};
|
|
|
|
// Only load a language sample when selected
|
|
useEffect(() => {
|
|
// Preserve user edits when switching back to an already-loaded language.
|
|
if (samples[language] !== undefined) return;
|
|
|
|
let cancelled = false;
|
|
fetch(`${basePath}${TEXT_EDITOR_SAMPLE_PATHS[language]}`)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error(`Failed to load sample: ${response.status}`);
|
|
return response.text();
|
|
})
|
|
.then(text => {
|
|
if (cancelled) return;
|
|
setSamples(samples =>
|
|
samples[language] === undefined
|
|
? { ...samples, [language]: trimTerminalNewline(text) }
|
|
: samples,
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setSamples(samples =>
|
|
samples[language] === undefined ? { ...samples, [language]: "" } : samples,
|
|
);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [basePath, language, samples]);
|
|
|
|
return (
|
|
<>
|
|
<TextEditorControls
|
|
fontSize={fontSize}
|
|
language={language}
|
|
onFontSizeChange={setFontSize}
|
|
onLanguageChange={setLanguage}
|
|
/>
|
|
<CustomCodeMirror
|
|
fontSize={fontSize}
|
|
fontStyle={fs}
|
|
hotChars={hotChars}
|
|
language={language}
|
|
onChange={value => setSamples(samples => ({ ...samples, [language]: value }))}
|
|
value={samples[language] ?? ""}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TextEditorControls(props: TextEditorControlProps) {
|
|
return (
|
|
<div className="customizer-body editor-toolbar">
|
|
<dl className="editor-controls">
|
|
<dt className="control-label">Language</dt>
|
|
<dd className="language-control">
|
|
<select
|
|
value={props.language}
|
|
onChange={e => props.onLanguageChange(e.target.value as TextEditorLanguage)}
|
|
>
|
|
{Object.values(TextEditorLanguage).map(language => (
|
|
<option key={language} value={language}>
|
|
{language}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</dd>
|
|
<dt className="control-label">Font Size</dt>
|
|
<dd className="font-size-control">
|
|
<input
|
|
type="range"
|
|
min={10}
|
|
max={32}
|
|
value={props.fontSize}
|
|
onChange={e => props.onFontSizeChange(Number(e.target.value))}
|
|
/>
|
|
<output>{props.fontSize}px</output>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
function CustomCodeMirror(props: TextEditorProps) {
|
|
// Re-render only when the language changes or when we use a different
|
|
// character variant/stylistic set/ligation set.
|
|
const extensions = useMemo(
|
|
() => [
|
|
getTextEditorLanguageExtension(props.language),
|
|
EditorView.lineWrapping,
|
|
perCharVariantExtension(props.hotChars),
|
|
],
|
|
[props.language, props.hotChars],
|
|
);
|
|
|
|
return (
|
|
<CodeMirror
|
|
className={Gr.fontStyleToCls(props.fontStyle)}
|
|
extensions={extensions}
|
|
maxHeight={TEXT_EDITOR_MAX_HEIGHT}
|
|
onChange={props.onChange}
|
|
style={{
|
|
...Gr.fontStyleToOtStyle(props.fontStyle),
|
|
fontSize: `${props.fontSize}px`,
|
|
}}
|
|
theme={oneDark}
|
|
value={props.value}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function getTextEditorLanguageExtension(language: TextEditorLanguage) {
|
|
switch (language) {
|
|
case TextEditorLanguage.TypeScript:
|
|
return javascript({ typescript: true });
|
|
case TextEditorLanguage.Python:
|
|
return python();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* We apply the "matching" feature for each highlighted character since Chromium
|
|
* may have problem when many features utilizing GSUB alternate is applied.
|
|
*/
|
|
function perCharVariantExtension(hotChars: StyleHelper.Highlights) {
|
|
const re = buildHotCharRegex(hotChars);
|
|
if (!re) return [];
|
|
|
|
const charToDecoration = new Map<string, Decoration>();
|
|
for (const [char, assignment] of hotChars) {
|
|
charToDecoration.set(
|
|
char,
|
|
Decoration.mark({
|
|
attributes: {
|
|
style: `font-feature-settings:${featureAssignmentToCss(assignment)}`,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const matcher = new MatchDecorator({
|
|
regexp: re,
|
|
decoration: match => charToDecoration.get(match[0]) ?? null,
|
|
});
|
|
|
|
return ViewPlugin.fromClass(
|
|
class {
|
|
decorations: DecorationSet;
|
|
constructor(view: EditorView) {
|
|
this.decorations = matcher.createDeco(view);
|
|
}
|
|
update(update: ViewUpdate) {
|
|
this.decorations = matcher.updateDeco(update, this.decorations);
|
|
}
|
|
},
|
|
{ decorations: v => v.decorations },
|
|
);
|
|
}
|
|
|
|
function TomlResultPanel() {
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const kPlan = pascalCase(cc.family);
|
|
const cfg = { buildPlans: { [kPlan]: formatBuildPlan(cc) } };
|
|
|
|
return (
|
|
<dl className="custom-build-commands">
|
|
<dt>
|
|
Your <code>private-build-plans.toml</code>:
|
|
</dt>
|
|
<dd className="custom-config-source">{Toml.stringify(cfg)}</dd>
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function BuildInstructionsPanel() {
|
|
const { val: cc } = useContext(CustomizerCtx);
|
|
const kPlan = pascalCase(cc.family);
|
|
return (
|
|
<dl className="custom-build-commands">
|
|
<dt>Command to build TTF + Web Font:</dt>
|
|
<dd>npm run build -- contents::{kPlan}</dd>
|
|
<dt>Command to build TTF:</dt>
|
|
<dd>npm run build -- ttf::{kPlan}</dd>
|
|
<dt>Command to build TTF Unhinted:</dt>
|
|
<dd>npm run build -- ttf-unhinted::{kPlan}</dd>
|
|
<dt>
|
|
For further information, see{" "}
|
|
<a href="https://github.com/be5invis/Iosevka/blob/master/doc/custom-build.md">
|
|
here
|
|
</a>
|
|
.
|
|
</dt>
|
|
</dl>
|
|
);
|
|
}
|