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} >