From bea3f7ae7f0dbf0bd14cbaf8fc5b6e2a5929744b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 21:33:30 +0300 Subject: [PATCH] chore: move store creators to separate directories --- .../model/stores/categoryFilterStore.ts | 2 +- .../model/stores/providersFilterStore.ts | 2 +- .../model/stores/subsetsFilterStore.ts | 2 +- .../SetupFont/model/stores/fontSizeStore.ts | 2 +- .../SetupFont/model/stores/fontWeightStore.ts | 2 +- .../SetupFont/model/stores/lineHeightStore.ts | 2 +- .../createControlStore/createControlStore.ts | 117 +++++++++ .../createFilterStore/createFilterStore.ts | 226 ++++++++++++++++++ 8 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/shared/lib/store/createControlStore/createControlStore.ts create mode 100644 src/shared/lib/store/createFilterStore/createFilterStore.ts diff --git a/src/features/FilterFonts/model/stores/categoryFilterStore.ts b/src/features/FilterFonts/model/stores/categoryFilterStore.ts index 60448c4..cf40782 100644 --- a/src/features/FilterFonts/model/stores/categoryFilterStore.ts +++ b/src/features/FilterFonts/model/stores/categoryFilterStore.ts @@ -1,7 +1,7 @@ import { type FilterModel, createFilterStore, -} from '$shared/store/createFilterStore'; +} from '$shared/lib/store/createFilterStore/createFilterStore'; import { FONT_CATEGORIES } from '../const/const'; /** diff --git a/src/features/FilterFonts/model/stores/providersFilterStore.ts b/src/features/FilterFonts/model/stores/providersFilterStore.ts index 489a202..bd9af2b 100644 --- a/src/features/FilterFonts/model/stores/providersFilterStore.ts +++ b/src/features/FilterFonts/model/stores/providersFilterStore.ts @@ -1,7 +1,7 @@ import { type FilterModel, createFilterStore, -} from '$shared/store/createFilterStore'; +} from '$shared/lib/store/createFilterStore/createFilterStore'; import { FONT_PROVIDERS } from '../const/const'; /** diff --git a/src/features/FilterFonts/model/stores/subsetsFilterStore.ts b/src/features/FilterFonts/model/stores/subsetsFilterStore.ts index 1df9378..7d99b50 100644 --- a/src/features/FilterFonts/model/stores/subsetsFilterStore.ts +++ b/src/features/FilterFonts/model/stores/subsetsFilterStore.ts @@ -1,7 +1,7 @@ import { type FilterModel, createFilterStore, -} from '$shared/store/createFilterStore'; +} from '$shared/lib/store/createFilterStore/createFilterStore'; import { FONT_SUBSETS } from '../const/const'; /** diff --git a/src/features/SetupFont/model/stores/fontSizeStore.ts b/src/features/SetupFont/model/stores/fontSizeStore.ts index b7cbea3..8105497 100644 --- a/src/features/SetupFont/model/stores/fontSizeStore.ts +++ b/src/features/SetupFont/model/stores/fontSizeStore.ts @@ -1,7 +1,7 @@ import { type ControlModel, createControlStore, -} from '$shared/store/createControlStore'; +} from '$shared/lib/store/createControlStore/createControlStore'; import { DEFAULT_FONT_SIZE, MAX_FONT_SIZE, diff --git a/src/features/SetupFont/model/stores/fontWeightStore.ts b/src/features/SetupFont/model/stores/fontWeightStore.ts index 4434088..689d768 100644 --- a/src/features/SetupFont/model/stores/fontWeightStore.ts +++ b/src/features/SetupFont/model/stores/fontWeightStore.ts @@ -1,7 +1,7 @@ import { type ControlModel, createControlStore, -} from '$shared/store/createControlStore'; +} from '$shared/lib/store/createControlStore/createControlStore'; import { DEFAULT_FONT_WEIGHT, FONT_WEIGHT_STEP, diff --git a/src/features/SetupFont/model/stores/lineHeightStore.ts b/src/features/SetupFont/model/stores/lineHeightStore.ts index 557ed76..8bb06a2 100644 --- a/src/features/SetupFont/model/stores/lineHeightStore.ts +++ b/src/features/SetupFont/model/stores/lineHeightStore.ts @@ -1,7 +1,7 @@ import { type ControlModel, createControlStore, -} from '$shared/store/createControlStore'; +} from '$shared/lib/store/createControlStore/createControlStore'; import { DEFAULT_LINE_HEIGHT, LINE_HEIGHT_STEP, diff --git a/src/shared/lib/store/createControlStore/createControlStore.ts b/src/shared/lib/store/createControlStore/createControlStore.ts new file mode 100644 index 0000000..a7e7463 --- /dev/null +++ b/src/shared/lib/store/createControlStore/createControlStore.ts @@ -0,0 +1,117 @@ +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/lib/store/createFilterStore/createFilterStore.ts b/src/shared/lib/store/createFilterStore/createFilterStore.ts new file mode 100644 index 0000000..a15ab0f --- /dev/null +++ b/src/shared/lib/store/createFilterStore/createFilterStore.ts @@ -0,0 +1,226 @@ +import { + type Readable, + type Writable, + derived, + writable, +} from 'svelte/store'; + +export interface Property { + /** + * Property identifier + */ + id: string; + /** + * Property name + */ + name: string; + /** + * Property selected state + */ + selected?: boolean; +} + +export interface FilterModel { + /** + * Search query + */ + searchQuery?: string; + /** + * Properties + */ + properties: Property[]; +} + +/** + * Model for reusable filter store with search support and property selection + */ +export interface FilterStore extends Writable { + /** + * Get the store. + * @returns Readable store with filter data + */ + getStore: () => Readable; + /** + * Get all properties. + * @returns Readable store with properties + */ + getAllProperties: () => Readable; + /** + * Get the selected properties. + * @returns Readable store with selected properties + */ + getSelectedProperties: () => Readable; + /** + * Get the filtered properties. + * @returns Readable store with filtered properties + */ + getFilteredProperties: () => Readable; + /** + * Update the search query filter. + * + * @param searchQuery - Search text (undefined to clear) + */ + setSearchQuery: (searchQuery: string | undefined) => void; + /** + * Clear the search query filter. + */ + clearSearchQuery: () => void; + /** + * Select a property. + * + * @param property - Property to select + */ + selectProperty: (propertyId: string) => void; + /** + * Deselect a property. + * + * @param property - Property to deselect + */ + deselectProperty: (propertyId: string) => void; + /** + * Toggle a property. + * + * @param propertyId - Property ID + */ + toggleProperty: (propertyId: string) => void; + /** + * Select all properties. + */ + selectAllProperties: () => void; + /** + * Deselect all properties. + */ + deselectAllProperties: () => void; +} + +/** + * Create a filter store. + * @param initialState - Initial state of the filter store + * @returns FilterStore + */ +export function createFilterStore( + initialState?: T, +): FilterStore { + const { subscribe, set, update } = writable(initialState); + + return { + /* + * Expose subscribe, set, and update from Writable. + * This makes FilterStore compatible with Writable interface. + */ + subscribe, + set, + update, + /** + * Get the current state of the filter store. + */ + getStore: () => { + return { + subscribe, + }; + }, + /** + * Get the filtered properties. + */ + getAllProperties: () => { + return derived({ subscribe }, $store => { + return $store.properties; + }); + }, + /** + * Get the selected properties. + */ + getSelectedProperties: () => { + return derived({ subscribe }, $store => { + return $store.properties.filter(property => property.selected); + }); + }, + /** + * Get the filtered properties. + */ + getFilteredProperties: () => { + return derived({ subscribe }, $store => { + return $store.properties.filter(property => + property.name.includes($store.searchQuery || '') + ); + }); + }, + /** + * Update the search query filter. + * + * @param searchQuery - Search text (undefined to clear) + */ + setSearchQuery: (searchQuery: string | undefined) => { + update(state => ({ + ...state, + searchQuery: searchQuery || undefined, + })); + }, + /** + * Clear the search query filter. + */ + clearSearchQuery: () => { + update(state => ({ + ...state, + searchQuery: undefined, + })); + }, + /** + * Select a property. + * + * @param propertyId - Property ID + */ + selectProperty: (propertyId: string) => { + update(state => ({ + ...state, + properties: state.properties.map(c => + c.id === propertyId ? { ...c, selected: true } : c + ), + })); + }, + /** + * Deselect a property. + * + * @param propertyId - Property ID + */ + deselectProperty: (propertyId: string) => { + update(state => ({ + ...state, + properties: state.properties.map(c => + c.id === propertyId ? { ...c, selected: false } : c + ), + })); + }, + /** + * Toggle a property. + * + * @param propertyId - Property ID + */ + toggleProperty: (propertyId: string) => { + update(state => ({ + ...state, + properties: state.properties.map(c => + c.id === propertyId ? { ...c, selected: !c.selected } : c + ), + })); + }, + /** + * Select all properties + */ + selectAllProperties: () => { + update(state => ({ + ...state, + properties: state.properties.map(c => ({ ...c, selected: true })), + })); + }, + /** + * Deselect all properties + */ + deselectAllProperties: () => { + update(state => ({ + ...state, + properties: state.properties.map(c => ({ ...c, selected: false })), + })); + }, + }; +}