diff --git a/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts
new file mode 100644
index 0000000..d9a2ac8
--- /dev/null
+++ b/src/features/SetupFont/lib/controlManager/controlManager.svelte.ts
@@ -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;
+ },
+ };
+}
diff --git a/src/features/SetupFont/model/state/manager.svetle.ts b/src/features/SetupFont/model/state/manager.svetle.ts
new file mode 100644
index 0000000..fb5c942
--- /dev/null
+++ b/src/features/SetupFont/model/state/manager.svetle.ts
@@ -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);
diff --git a/src/features/SetupFont/model/stores/fontSizeStore.ts b/src/features/SetupFont/model/stores/fontSizeStore.ts
deleted file mode 100644
index 8105497..0000000
--- a/src/features/SetupFont/model/stores/fontSizeStore.ts
+++ /dev/null
@@ -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);
diff --git a/src/features/SetupFont/model/stores/fontWeightStore.ts b/src/features/SetupFont/model/stores/fontWeightStore.ts
deleted file mode 100644
index 689d768..0000000
--- a/src/features/SetupFont/model/stores/fontWeightStore.ts
+++ /dev/null
@@ -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);
diff --git a/src/features/SetupFont/model/stores/lineHeightStore.ts b/src/features/SetupFont/model/stores/lineHeightStore.ts
deleted file mode 100644
index 8bb06a2..0000000
--- a/src/features/SetupFont/model/stores/lineHeightStore.ts
+++ /dev/null
@@ -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);
diff --git a/src/features/SetupFont/ui/SetupFontMenu.svelte b/src/features/SetupFont/ui/SetupFontMenu.svelte
index 383c928..0558d0d 100644
--- a/src/features/SetupFont/ui/SetupFontMenu.svelte
+++ b/src/features/SetupFont/ui/SetupFontMenu.svelte
@@ -1,55 +1,14 @@
-
+
-
-
-
+ {#each controlManager.controls as control (control.id)}
+
+ {/each}
diff --git a/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts
new file mode 100644
index 0000000..f779a03
--- /dev/null
+++ b/src/shared/lib/helpers/createTypographyControl/createTypographyControl.svelte.ts
@@ -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
(
+ 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;
diff --git a/src/shared/lib/store/createControlStore/createControlStore.test.ts b/src/shared/lib/store/createControlStore/createControlStore.test.ts
deleted file mode 100644
index fb9ce03..0000000
--- a/src/shared/lib/store/createControlStore/createControlStore.test.ts
+++ /dev/null
@@ -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>;
-
- beforeEach(() => {
- const initialState: ControlModel = {
- 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);
- });
-});
diff --git a/src/shared/lib/store/createControlStore/createControlStore.ts b/src/shared/lib/store/createControlStore/createControlStore.ts
deleted file mode 100644
index a7e7463..0000000
--- a/src/shared/lib/store/createControlStore/createControlStore.ts
+++ /dev/null
@@ -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>
- & {
- 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,
-): ControlStoreModel {
- 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,
- };
-}
diff --git a/src/shared/ui/ComboControl/ComboControl.svelte b/src/shared/ui/ComboControl/ComboControl.svelte
index e600d00..301034b 100644
--- a/src/shared/ui/ComboControl/ComboControl.svelte
+++ b/src/shared/ui/ComboControl/ComboControl.svelte
@@ -1,4 +1,5 @@
@@ -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}
>
@@ -117,16 +78,16 @@ const handleSliderChange = (value: number) => {
size="icon"
aria-label={controlLabel}
>
- {value}
+ {control.value}
{/snippet}
{
class="h-48"
/>
@@ -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}
>