feature/fetch-fonts #14

Merged
ilia merged 76 commits from feature/fetch-fonts into main 2026-01-14 11:01:44 +00:00
10 changed files with 178 additions and 368 deletions
Showing only changes of commit 76f27a64b2 - Show all commits

View File

@@ -0,0 +1,22 @@
import {
type ControlModel,
createTypographyControl,
} from '$shared/lib';
export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state(
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
})),
);
return {
get controls() {
return controls;
},
};
}

View File

@@ -0,0 +1,56 @@
import {
createTypographyControlManager,
} from '$features/SetupFont/lib/controlManager/controlManager.svelte';
import type { ControlModel } from '$shared/lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const controlData: ControlModel[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
},
];
export const controlManager = createTypographyControlManager(controlData);

View File

@@ -1,17 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
};
export const fontSizeStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_FONT_WEIGHT,
FONT_WEIGHT_STEP,
MAX_FONT_WEIGHT,
MIN_FONT_WEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
};
export const fontWeightStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_LINE_HEIGHT,
LINE_HEIGHT_STEP,
MAX_LINE_HEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
};
export const lineHeightStore = createControlStore(initialValue);

View File

@@ -1,55 +1,14 @@
<script lang="ts">
import * as Item from '$shared/shadcn/ui/item';
import { Separator } from '$shared/shadcn/ui/separator/index';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte';
import { fontSizeStore } from '../model/stores/fontSizeStore';
import { fontWeightStore } from '../model/stores/fontWeightStore';
import { lineHeightStore } from '../model/stores/lineHeightStore';
const fontSize = $derived($fontSizeStore);
const fontWeight = $derived($fontWeightStore);
const lineHeight = $derived($lineHeightStore);
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model/state/manager.svetle';
</script>
<div class="w-full p-2 flex flex-row items-center">
<div class="w-full p-2 flex flex-row items-center gap-2">
<Sidebar.Trigger />
<Separator orientation="vertical" class="h-full" />
<ComboControl
value={fontSize.value}
minValue={fontSize.min}
maxValue={fontSize.max}
onChange={fontSizeStore.setValue}
onIncrease={fontSizeStore.increase}
onDecrease={fontSizeStore.decrease}
increaseDisabled={fontSizeStore.isAtMax()}
decreaseDisabled={fontSizeStore.isAtMin()}
increaseLabel="Increase Font Size"
decreaseLabel="Decrease Font Size"
/>
<ComboControl
value={fontWeight.value}
minValue={fontWeight.min}
maxValue={fontWeight.max}
onChange={fontWeightStore.setValue}
onIncrease={fontWeightStore.increase}
onDecrease={fontWeightStore.decrease}
increaseDisabled={fontWeightStore.isAtMax()}
decreaseDisabled={fontWeightStore.isAtMin()}
increaseLabel="Increase Font Weight"
decreaseLabel="Decrease Font Weight"
/>
<ComboControl
value={lineHeight.value}
minValue={lineHeight.min}
maxValue={lineHeight.max}
step={lineHeight.step}
onChange={lineHeightStore.setValue}
onIncrease={lineHeightStore.increase}
onDecrease={lineHeightStore.decrease}
increaseDisabled={lineHeightStore.isAtMax()}
decreaseDisabled={lineHeightStore.isAtMin()}
increaseLabel="Increase Line Height"
decreaseLabel="Decrease Line Height"
/>
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
</div>

View File

@@ -0,0 +1,73 @@
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
export interface ControlDataModel {
value: number;
min: number;
max: number;
step: number;
}
export interface ControlModel extends ControlDataModel {
id: string;
increaseLabel: string;
decreaseLabel: string;
controlLabel: string;
}
export function createTypographyControl<T extends ControlDataModel>(
initialState: T,
) {
let value = $state(initialState.value);
let max = $state(initialState.max);
let min = $state(initialState.min);
let step = $state(initialState.step);
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
get value() {
return value;
},
set value(newValue) {
value = roundToStepPrecision(
clampNumber(newValue, min, max),
step,
);
},
get max() {
return max;
},
get min() {
return min;
},
get step() {
return step;
},
get isAtMax() {
return isAtMax;
},
get isAtMin() {
return isAtMin;
},
increase() {
value = roundToStepPrecision(
clampNumber(value + step, min, max),
step,
);
},
decrease() {
value = roundToStepPrecision(
clampNumber(value - step, min, max),
step,
);
},
};
}
export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -1,89 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type ControlModel,
createControlStore,
} from './createControlStore';
describe('createControlStore', () => {
let store: ReturnType<typeof createControlStore<number>>;
beforeEach(() => {
const initialState: ControlModel<number> = {
value: 10,
min: 0,
max: 100,
step: 5,
};
store = createControlStore(initialState);
});
it('initializes with correct state', () => {
expect(get(store)).toEqual({
value: 10,
min: 0,
max: 100,
step: 5,
});
});
it('increases value by step', () => {
store.increase();
expect(get(store).value).toBe(15);
});
it('decreases value by step', () => {
store.decrease();
expect(get(store).value).toBe(5);
});
it('clamps value at maximum', () => {
store.setValue(200);
expect(get(store).value).toBe(100);
});
it('clamps value at minimum', () => {
store.setValue(-10);
expect(get(store).value).toBe(0);
});
it('rounds to step precision', () => {
store.setValue(12.34);
// With step=5, 12.34 is clamped and rounded to nearest integer (0 decimal places)
expect(get(store).value).toBe(12);
});
it('handles decimal steps correctly', () => {
const decimalStore = createControlStore({
value: 1.0,
min: 0,
max: 2,
step: 0.05,
});
decimalStore.increase();
expect(get(decimalStore).value).toBe(1.05);
});
it('isAtMax returns true when at maximum', () => {
store.setValue(100);
expect(store.isAtMax()).toBe(true);
});
it('isAtMax returns false when not at maximum', () => {
expect(store.isAtMax()).toBe(false);
});
it('isAtMin returns true when at minimum', () => {
store.setValue(0);
expect(store.isAtMin()).toBe(true);
});
it('isAtMin returns false when not at minimum', () => {
expect(store.isAtMin()).toBe(false);
});
});

View File

@@ -1,117 +0,0 @@
import {
type Writable,
get,
writable,
} from 'svelte/store';
/**
* Model for a control value with min/max bounds
*/
export type ControlModel<
TValue extends number = number,
> = {
value: TValue;
min: TValue;
max: TValue;
step?: TValue;
};
/**
* Store model with methods for control manipulation
*/
export type ControlStoreModel<
TValue extends number,
> =
& Writable<ControlModel<TValue>>
& {
increase: () => void;
decrease: () => void;
/** Set a specific value */
setValue: (newValue: TValue) => void;
isAtMax: () => boolean;
isAtMin: () => boolean;
};
/**
* Create a writable store for numeric control values with bounds
*
* @template TValue - The value type (extends number)
* @param initialState - Initial state containing value, min, and max
*/
/**
* Get the number of decimal places in a number
*
* For example:
* - 1 -> 0
* - 0.1 -> 1
* - 0.01 -> 2
* - 0.05 -> 2
*
* @param step - The step number to analyze
* @returns The number of decimal places
*/
function getDecimalPlaces(step: number): number {
const str = step.toString();
const decimalPart = str.split('.')[1];
return decimalPart ? decimalPart.length : 0;
}
/**
* Round a value to the precision of the given step
*
* This fixes floating-point precision errors that occur with decimal steps.
* For example, with step=0.05, adding it repeatedly can produce values like
* 1.3499999999999999 instead of 1.35.
*
* We use toFixed() to round to the appropriate decimal places instead of
* Math.round(value / step) * step, which doesn't always work correctly
* due to floating-point arithmetic errors.
*
* @param value - The value to round
* @param step - The step to round to (defaults to 1)
* @returns The rounded value
*/
function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {
return value;
}
const decimals = getDecimalPlaces(step);
return parseFloat(value.toFixed(decimals));
}
export function createControlStore<
TValue extends number = number,
>(
initialState: ControlModel<TValue>,
): ControlStoreModel<TValue> {
const store = writable(initialState);
const { subscribe, set, update } = store;
const clamp = (value: number): TValue => {
return Math.max(initialState.min, Math.min(value, initialState.max)) as TValue;
};
return {
subscribe,
set,
update,
increase: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value + step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
decrease: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value - step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
setValue: (v: TValue) => {
const step = initialState.step ?? 1;
update(m => ({ ...m, value: roundToStepPrecision(clamp(v), step) as TValue }));
},
isAtMin: () => get(store).value === initialState.min,
isAtMax: () => get(store).value === initialState.max,
};
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input';
@@ -9,22 +10,6 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements';
interface ComboControlProps {
/**
* Controlled value
*/
value: number;
/**
* Callback function to handle value change
*/
onChange: (value: number) => void;
/**
* Callback function to handle increase
*/
onIncrease: () => void;
/**
* Callback function to handle decrease
*/
onDecrease: () => void;
/**
* Text for increase button aria-label
*/
@@ -33,59 +18,35 @@ interface ComboControlProps {
* Text for decrease button aria-label
*/
decreaseLabel?: string;
/**
* Flag for disabling increase button
*/
increaseDisabled?: boolean;
/**
* Flag for disabling decrease button
*/
decreaseDisabled?: boolean;
/**
* Text for control button aria-label
*/
controlLabel?: string;
/**
* Minimum value for the input
* Control instance
*/
minValue?: number;
/**
* Maximum value for the input
*/
maxValue?: number;
/**
* Step value for the slider
*/
step?: number;
control: TypographyControl;
}
const {
value,
onChange,
onIncrease,
onDecrease,
increaseLabel,
control,
decreaseLabel,
increaseDisabled,
decreaseDisabled,
increaseLabel,
controlLabel,
minValue = 0,
maxValue = 100,
step = 1,
}: ComboControlProps = $props();
// Local state for the slider to prevent infinite loops
let sliderValue = $state(value);
let sliderValue = $state(Number(control.value));
// Sync sliderValue when external value changes
$effect(() => {
sliderValue = value;
sliderValue = Number(control.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
onChange(parsedValue);
control.value = parsedValue;
}
};
@@ -93,8 +54,8 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
* Handle slider value change.
* The Slider component passes the value as a number directly.
*/
const handleSliderChange = (value: number) => {
onChange(value);
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
</script>
@@ -103,8 +64,8 @@ const handleSliderChange = (value: number) => {
variant="outline"
size="icon"
aria-label={decreaseLabel}
onclick={onDecrease}
disabled={decreaseDisabled}
onclick={control.decrease}
disabled={control.isAtMin}
>
<MinusIcon />
</Button>
@@ -117,16 +78,16 @@ const handleSliderChange = (value: number) => {
size="icon"
aria-label={controlLabel}
>
{value}
{control.value}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-4">
<div class="flex flex-col items-center gap-3">
<Slider
min={minValue}
max={maxValue}
step={step}
min={control.min}
max={control.max}
step={control.step}
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
@@ -134,10 +95,10 @@ const handleSliderChange = (value: number) => {
class="h-48"
/>
<Input
value={String(value)}
min={minValue}
max={maxValue}
value={control.value}
onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-16 text-center"
/>
</div>
@@ -147,8 +108,8 @@ const handleSliderChange = (value: number) => {
variant="outline"
size="icon"
aria-label={increaseLabel}
onclick={onIncrease}
disabled={increaseDisabled}
onclick={control.increase}
disabled={control.isAtMax}
>
<PlusIcon />
</Button>