bsign-customization-full 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.env +2 -0
  2. package/components.json +21 -0
  3. package/dist/colors/anthracite-gray.webp +0 -0
  4. package/dist/colors/anthracite-gray_50x50.png +0 -0
  5. package/dist/colors/dark-wenge.webp +0 -0
  6. package/dist/colors/dark-wenge_50x50.png +0 -0
  7. package/dist/colors/indian-rosewood.webp +0 -0
  8. package/dist/colors/indian-rosewood_50x50.png +0 -0
  9. package/dist/colors/natural-wood.webp +0 -0
  10. package/dist/colors/natural-wood_50x50.png +0 -0
  11. package/dist/colors/redwood.webp +0 -0
  12. package/dist/colors/redwood_50x50.png +0 -0
  13. package/dist/colors/walnut.webp +0 -0
  14. package/dist/colors/walnut_50x50.png +0 -0
  15. package/dist/html2canvas.esm-BJ_egzt0.js +4802 -0
  16. package/dist/index-Dw5Zc1iD.js +33162 -0
  17. package/dist/index.es-Co1KNpGS.js +6681 -0
  18. package/dist/logo.png +0 -0
  19. package/dist/purify.es-CKpD2xIC.js +552 -0
  20. package/dist/sign-constructor.es.js +4 -0
  21. package/dist/sign-constructor.iife.js +171 -0
  22. package/dist/size-guide.webp +0 -0
  23. package/dist/size.webp +0 -0
  24. package/dist/templates/assets/modern/rectangle-black.webp +0 -0
  25. package/dist/templates/assets/modern/rectangle-white.webp +0 -0
  26. package/dist/templates/assets/modern/square-black.webp +0 -0
  27. package/dist/templates/assets/modern/square-white.webp +0 -0
  28. package/dist/templates/assets/wave.webp +0 -0
  29. package/dist/templates/jure.webp +0 -0
  30. package/dist/templates/modern.webp +0 -0
  31. package/dist/templates/sherwood.webp +0 -0
  32. package/dist/templates/wave.webp +0 -0
  33. package/eslint.config.js +23 -0
  34. package/index.html +13 -0
  35. package/modern-debug.svg +39 -0
  36. package/package.json +62 -0
  37. package/public/colors/anthracite-gray.webp +0 -0
  38. package/public/colors/anthracite-gray_50x50.png +0 -0
  39. package/public/colors/dark-wenge.webp +0 -0
  40. package/public/colors/dark-wenge_50x50.png +0 -0
  41. package/public/colors/indian-rosewood.webp +0 -0
  42. package/public/colors/indian-rosewood_50x50.png +0 -0
  43. package/public/colors/natural-wood.webp +0 -0
  44. package/public/colors/natural-wood_50x50.png +0 -0
  45. package/public/colors/redwood.webp +0 -0
  46. package/public/colors/redwood_50x50.png +0 -0
  47. package/public/colors/walnut.webp +0 -0
  48. package/public/colors/walnut_50x50.png +0 -0
  49. package/public/logo.png +0 -0
  50. package/public/size-guide.webp +0 -0
  51. package/public/size.webp +0 -0
  52. package/public/templates/assets/modern/rectangle-black.webp +0 -0
  53. package/public/templates/assets/modern/rectangle-white.webp +0 -0
  54. package/public/templates/assets/modern/square-black.webp +0 -0
  55. package/public/templates/assets/modern/square-white.webp +0 -0
  56. package/public/templates/assets/wave.webp +0 -0
  57. package/public/templates/jure.webp +0 -0
  58. package/public/templates/modern.webp +0 -0
  59. package/public/templates/sherwood.webp +0 -0
  60. package/public/templates/wave.webp +0 -0
  61. package/src/App.css +43 -0
  62. package/src/AppDemo2.tsx +257 -0
  63. package/src/components/cart-panel.tsx +170 -0
  64. package/src/components/cart-preview.tsx +356 -0
  65. package/src/components/cart-view.tsx +113 -0
  66. package/src/components/constructure-menu.tsx +37 -0
  67. package/src/components/header.tsx +214 -0
  68. package/src/components/heading.tsx +28 -0
  69. package/src/components/icons.tsx +54 -0
  70. package/src/components/import-file-modal.tsx +252 -0
  71. package/src/components/layers/grid-view.tsx +29 -0
  72. package/src/components/layers/image-layer.tsx +128 -0
  73. package/src/components/layers/layer-forms/material-form.tsx +53 -0
  74. package/src/components/layers/layer-forms/size-form.tsx +69 -0
  75. package/src/components/layers/layer-forms/template-form.tsx +39 -0
  76. package/src/components/layers/layer-forms/text-form.tsx +477 -0
  77. package/src/components/layers/layers-container.tsx +259 -0
  78. package/src/components/layers/text-layer.tsx +128 -0
  79. package/src/components/movable-item.tsx +228 -0
  80. package/src/components/preview.tsx +258 -0
  81. package/src/components/resize-button.tsx +83 -0
  82. package/src/components/size-guide-modal.tsx +47 -0
  83. package/src/components/size-guide.tsx +98 -0
  84. package/src/components/ui/button-group.tsx +83 -0
  85. package/src/components/ui/button.tsx +60 -0
  86. package/src/components/ui/checkbox.tsx +30 -0
  87. package/src/components/ui/dialog.tsx +151 -0
  88. package/src/components/ui/input-group.tsx +168 -0
  89. package/src/components/ui/input.tsx +21 -0
  90. package/src/components/ui/label.tsx +22 -0
  91. package/src/components/ui/popover.tsx +54 -0
  92. package/src/components/ui/progress.tsx +28 -0
  93. package/src/components/ui/radio-group.tsx +43 -0
  94. package/src/components/ui/scroll-area.tsx +56 -0
  95. package/src/components/ui/select.tsx +191 -0
  96. package/src/components/ui/separator.tsx +25 -0
  97. package/src/components/ui/sheet.tsx +141 -0
  98. package/src/components/ui/slider.tsx +61 -0
  99. package/src/components/ui/spinner.tsx +15 -0
  100. package/src/components/ui/textarea.tsx +18 -0
  101. package/src/components/ui/toggle-group.tsx +73 -0
  102. package/src/components/ui/toggle.tsx +45 -0
  103. package/src/components/ui/tooltip.tsx +67 -0
  104. package/src/fonts/BEBASNEUE-REGULAR.TTF +0 -0
  105. package/src/fonts/Braille-Regular.ttf +0 -0
  106. package/src/fonts/GOTHICB.TTF +0 -0
  107. package/src/hooks/use-mobile.ts +23 -0
  108. package/src/hooks/use-resize-constraints.ts +62 -0
  109. package/src/index.css +238 -0
  110. package/src/index.tsx +141 -0
  111. package/src/lib/cart-proposal-pdf.ts +350 -0
  112. package/src/lib/config-font.tsx +109 -0
  113. package/src/lib/config.ts +730 -0
  114. package/src/lib/pricing.ts +61 -0
  115. package/src/lib/type-checks.ts +47 -0
  116. package/src/lib/utils.ts +146 -0
  117. package/src/lib/widget-context.tsx +9 -0
  118. package/src/main.tsx +11 -0
  119. package/src/store/cart-store.ts +78 -0
  120. package/src/store/layers-store.ts +337 -0
  121. package/src/vite-env.d.ts +1 -0
  122. package/test/preview.html +37 -0
  123. package/tsconfig.app.json +33 -0
  124. package/tsconfig.json +13 -0
  125. package/tsconfig.node.json +25 -0
  126. package/vite.config.ts +38 -0
@@ -0,0 +1,61 @@
1
+ import type { TCartItem } from "../store/cart-store";
2
+ import type { TSize } from "./config";
3
+
4
+ export const CURRENCY_SYMBOL = "$";
5
+
6
+ export const QUANTITY_DISCOUNT_TIERS = [
7
+ { qty: 1, discount: 0 },
8
+ { qty: 10, discount: 5 },
9
+ { qty: 25, discount: 10 },
10
+ { qty: 50, discount: 15 },
11
+ ] as const;
12
+
13
+ export function formatPriceAmount(value: number): string {
14
+ return new Intl.NumberFormat("en-US", {
15
+ minimumFractionDigits: 2,
16
+ maximumFractionDigits: 2,
17
+ }).format(value);
18
+ }
19
+
20
+ export function formatPrice(value: number): string {
21
+ return `${CURRENCY_SYMBOL}${formatPriceAmount(value)}`;
22
+ }
23
+
24
+ export function getBasePriceBySize(size: Pick<TSize, "price">): number {
25
+ return size.price;
26
+ }
27
+
28
+ export function getDiscountByQty(qty: number): number {
29
+ const safeQty = Number.isFinite(qty) && qty > 0 ? Math.floor(qty) : 1;
30
+
31
+ return QUANTITY_DISCOUNT_TIERS.reduce((currentDiscount, tier) => {
32
+ if (safeQty >= tier.qty) {
33
+ return tier.discount;
34
+ }
35
+
36
+ return currentDiscount;
37
+ }, 0);
38
+ }
39
+
40
+ export function getUnitPrice(size: Pick<TSize, "price">, qty: number): number {
41
+ const basePrice = getBasePriceBySize(size);
42
+ const discount = getDiscountByQty(qty);
43
+ const discountedPrice = basePrice * (1 - discount / 100);
44
+
45
+ return Number(discountedPrice.toFixed(2));
46
+ }
47
+
48
+ export function getCartSummary(cart: TCartItem[]): { totalQty: number; totalAmount: number } {
49
+ return cart.reduce(
50
+ (acc, item) => {
51
+ const safeQty = Number.isFinite(item.qty) && item.qty > 0 ? Math.floor(item.qty) : 1;
52
+ const unitPrice = getUnitPrice(item.options.selectedSize, safeQty);
53
+
54
+ acc.totalQty += safeQty;
55
+ acc.totalAmount += unitPrice * safeQty;
56
+
57
+ return acc;
58
+ },
59
+ { totalQty: 0, totalAmount: 0 }
60
+ );
61
+ }
@@ -0,0 +1,47 @@
1
+ import type { LayerImageProps } from "../components/layers/image-layer";
2
+ import type { LayerTextProps } from "../components/layers/text-layer";
3
+ import type { LayrProps, TLayer } from "../components/preview";
4
+
5
+ export function isLayerImageProps(layer: LayrProps): layer is LayerImageProps {
6
+ return (
7
+ (layer as LayerImageProps).imageSrc !== undefined
8
+ );
9
+ }
10
+
11
+ export function isLayerTextProps(layer: LayrProps): layer is LayerTextProps {
12
+ return (
13
+ typeof (layer as LayerTextProps).text === 'string' &&
14
+ typeof (layer as LayerTextProps).fontSize === 'number'
15
+ );
16
+ }
17
+
18
+ /** Перевіряє, чи layer є "image-layer" */
19
+ export function isImageLayer(
20
+ layer: TLayer<LayrProps>
21
+ ): layer is TLayer<LayerImageProps> {
22
+ return (
23
+ layer.type === 'image' &&
24
+ typeof (layer.props as LayerImageProps).imageSrc === 'string'
25
+ );
26
+ }
27
+
28
+ /** Перевіряє, чи layer є "background-layer" */
29
+ export function isBackgroundLayer(
30
+ layer: TLayer<LayrProps>
31
+ ): layer is TLayer<LayerImageProps> {
32
+ return (
33
+ layer.type === 'background' &&
34
+ typeof (layer.props as { imageSrc: string }).imageSrc === 'string'
35
+ );
36
+ }
37
+
38
+ /** Перевіряє, чи layer є "text-layer" */
39
+ export function isTextLayer(
40
+ layer: TLayer<LayrProps>
41
+ ): layer is TLayer<LayerTextProps> {
42
+ return (
43
+ layer.type === 'text' &&
44
+ typeof (layer.props as LayerTextProps).text === 'string' &&
45
+ typeof (layer.props as LayerTextProps).fontSize === 'number'
46
+ );
47
+ }
@@ -0,0 +1,146 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+ import Papa from "papaparse";
4
+ import { saveAs } from "file-saver";
5
+
6
+ export function cn(...inputs: ClassValue[]) {
7
+ return twMerge(clsx(inputs))
8
+ }
9
+
10
+ /**
11
+ * Обчислює співвідношення сторін, наприклад 16:9, 1:2 або 1:1.2
12
+ *
13
+ * @param w — ширина
14
+ * @param h — висота
15
+ * @param maxDen — (необов’язково) максимальний знаменник у шуканому раціональному наближенні.
16
+ * Для цілих чисел роль maxDen не відіграє; для дробових значень
17
+ * використовується алгоритм обмежених дробів (continued fractions).
18
+ * За замовчуванням 100.
19
+ * @returns — рядок у форматі "x:y"
20
+ */
21
+ export function aspectRatio(
22
+ w: number,
23
+ h: number,
24
+ maxDen: number = 100,
25
+ ): string {
26
+ if (w <= 0 || h <= 0 || !isFinite(w) || !isFinite(h)) {
27
+ throw new Error("Widths and heights must be finite positive numbers");
28
+ }
29
+
30
+ // Якщо обидва числа цілі — просто скорочуємо через НСД
31
+ if (Number.isInteger(w) && Number.isInteger(h)) {
32
+ const gcd = (a: number, b: number): number =>
33
+ b === 0 ? a : gcd(b, a % b);
34
+
35
+ const g = gcd(w, h);
36
+ return `${w / g}/${h / g}`;
37
+ }
38
+
39
+ // Для дробових — раціональне наближення за допомогою
40
+ // алгоритму обмежених дробів / Farey, щоби мати приємні числа
41
+ const target = w / h;
42
+
43
+ let bestNum = 1;
44
+ let bestDen = 1;
45
+ let minError = Math.abs(target - bestNum / bestDen);
46
+
47
+ for (let den = 1; den <= maxDen; den++) {
48
+ const num = Math.round(target * den);
49
+ const value = num / den;
50
+ const error = Math.abs(target - value);
51
+
52
+ if (error < minError) {
53
+ minError = error;
54
+ bestNum = num;
55
+ bestDen = den;
56
+ if (error === 0) break; // ідеальне співпадіння
57
+ }
58
+ }
59
+
60
+ // Скорочуємо знайдену пару ще раз, раптом num та den мають спільні дільники
61
+ const gcd = (a: number, b: number): number =>
62
+ b === 0 ? a : gcd(b, a % b);
63
+
64
+ const g = gcd(bestNum, bestDen);
65
+ bestNum /= g;
66
+ bestDen /= g;
67
+
68
+ return `${bestNum}/${bestDen}`;
69
+ }
70
+
71
+ type CsvRow = Record<string, unknown>;
72
+
73
+ export function downloadCsv<T extends CsvRow>(
74
+ rows: T[],
75
+ filename = "data.csv",
76
+ options?: Papa.UnparseConfig // delimiter, header, columns, etc.
77
+ ) {
78
+ // Приклад: за замовчуванням додаємо header
79
+ const csv = Papa.unparse(rows, { header: true, ...options });
80
+
81
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
82
+ saveAs(blob, filename);
83
+ }
84
+
85
+ export interface ParseCsvOptions<T> {
86
+ delimiter?: string; // ",", ";", "\t", ...
87
+ dynamicTyping?: boolean | object; // true щоб конвертувати числа/булеві
88
+ transformHeader?: (header: string) => string; // нормалізація назв колонок
89
+ beforeRow?: (row: T) => T; // опціональна постобробка кожного рядка
90
+ }
91
+
92
+ /**
93
+ * Зчитує CSV-файл і повертає масив об'єктів (по заголовкам).
94
+ * Використовує Web Worker для великих файлів.
95
+ */
96
+ export function parseCsvFile<T extends CsvRow = CsvRow>(
97
+ file: File,
98
+ opts: ParseCsvOptions<T> = {}
99
+ ): Promise<T[]> {
100
+ const useWorker = !(opts.beforeRow || opts.transformHeader); // безпечніше для функцій
101
+
102
+ return new Promise<T[]>((resolve, reject) => {
103
+ Papa.parse(file, {
104
+ header: true,
105
+ skipEmptyLines: "greedy",
106
+ worker: useWorker,
107
+ delimiter: opts.delimiter,
108
+ // dynamicTyping: opts.dynamicTyping ?? true,
109
+ transformHeader: opts.transformHeader,
110
+ complete: (result) => {
111
+ // 1) Нормальний шлях: Papa віддав масив об’єктів
112
+ let data = (result.data as unknown as T[]).filter(
113
+ (r) => r && Object.keys(r).length > 0
114
+ );
115
+
116
+ // 2) Якщо headers не розпізнані (наприклад, не той роздільник),
117
+ // пробуємо трактувати перший рядок як заголовки самостійно.
118
+ if ((!result.meta.fields || result.meta.fields.length === 0) && Array.isArray(result.data)) {
119
+ const rows = result.data as unknown as any[][];
120
+ if (rows.length > 1 && Array.isArray(rows[0])) {
121
+ const [headers, ...rest] = rows;
122
+ data = rest.map((row) =>
123
+ Object.fromEntries(
124
+ headers.map((h, i) => [String(h).trim(), row?.[i] ?? ""])
125
+ )
126
+ ) as T[];
127
+ }
128
+ }
129
+
130
+ resolve(opts.beforeRow ? data.map(opts.beforeRow) : data);
131
+ },
132
+ error: (err) => reject(err),
133
+ });
134
+ });
135
+ }
136
+
137
+ export function arraysEqual<T>(a: T[], b: T[]): boolean {
138
+ if (a === b) return true;
139
+ if (a == null || b == null || a.length !== b.length) return false;
140
+
141
+ return a.every((val, index) => val === b[index]);
142
+ }
143
+
144
+ export async function sleep(ms: number): Promise<void> {
145
+ return new Promise(resolve => setTimeout(resolve, ms));
146
+ }
@@ -0,0 +1,9 @@
1
+ import { createContext, useContext } from "react"
2
+
3
+ export const WidgetPortalContainerContext = createContext<HTMLElement | null>(
4
+ null
5
+ )
6
+
7
+ export function useWidgetPortalContainer() {
8
+ return useContext(WidgetPortalContainerContext)
9
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,11 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './AppDemo2.tsx'
4
+ import 'react-resizable/css/styles.css'
5
+ import './index.css'
6
+
7
+ createRoot(document.getElementById('root')!).render(
8
+ <StrictMode>
9
+ <App />
10
+ </StrictMode>,
11
+ )
@@ -0,0 +1,78 @@
1
+ import { create } from 'zustand'
2
+ import type { LayrProps, TLayer } from '../components/preview'
3
+ import type { TLayersOption } from './layers-store';
4
+
5
+ export type TCartItem = {
6
+ sign: TLayer<LayrProps>[];
7
+ options: TLayersOption;
8
+ qty: number;
9
+ id: number;
10
+ img: string;
11
+ }
12
+
13
+ type CartStore = {
14
+ cart: TCartItem[];
15
+ add: (newSign: TLayer<LayrProps>[], preview: string, options: TLayersOption, qty?: number) => TCartItem;
16
+ update: (id: number, data: Omit<Partial<TCartItem>, 'id'>, contextUpdate?: (prev: TCartItem) => Omit<Partial<TCartItem>, 'id'>) => void;
17
+ init: (cart: TCartItem[]) => void;
18
+ }
19
+
20
+ export const useCartStore = create<CartStore>()((set) => ({
21
+ cart: [],
22
+ init: (cart) => set(() => ({ cart })),
23
+ add: (newSign, preview, options, qty = 1) => {
24
+ let id = new Date().getTime();
25
+ const normalizedQty = Number.isFinite(qty) && qty > 0 ? Math.floor(qty) : 1;
26
+ // Глибока копія, щоб від’єднатися від редактора
27
+ // Це піздець, скільки це лайно зжерло моїх нервів
28
+ const signCopy = typeof structuredClone === 'function'
29
+ ? structuredClone(newSign)
30
+ : JSON.parse(JSON.stringify(newSign));
31
+
32
+ const newCartItem: TCartItem = {
33
+ sign: signCopy,
34
+ qty: normalizedQty,
35
+ id,
36
+ img: preview,
37
+ options
38
+ };
39
+
40
+ set((state) => {
41
+
42
+ // При імпорті в моменті створюється декілька записів,
43
+ // Тому варто перевіряти на унікальність
44
+ let exists = state.cart.find(item => item.id === id);
45
+ if (exists) {
46
+ while (exists) {
47
+ id += 1;
48
+ exists = state.cart.find(item => item.id === id);
49
+ }
50
+ newCartItem.id = id;
51
+ }
52
+
53
+ return {
54
+ cart: [...state.cart, newCartItem],
55
+ }
56
+ });
57
+ return newCartItem;
58
+ },
59
+
60
+ update: (id, data, contextUpdate) =>
61
+ set((state) => ({
62
+ cart: state.cart.map((item) => {
63
+ if (item.id !== id) return item;
64
+
65
+ const contextData = contextUpdate ? contextUpdate(item) : {};
66
+ const next: Partial<TCartItem> = { ...data, ...contextData };
67
+
68
+ if (data.sign) {
69
+ next.sign = typeof structuredClone === 'function'
70
+ ? structuredClone(data.sign)
71
+ : JSON.parse(JSON.stringify(data.sign));
72
+ }
73
+
74
+ return { ...item, ...next };
75
+ }),
76
+ })),
77
+ }))
78
+
@@ -0,0 +1,337 @@
1
+ import { create } from 'zustand'
2
+ import type { LayrProps, TLayer } from '../components/preview'
3
+ import {
4
+ applyTemplateVisualsToLayers,
5
+ createTemplateLayers,
6
+ getDefaultTemplateOptions,
7
+ resolveTemplateSelection,
8
+ type TSize,
9
+ } from '../lib/config'
10
+
11
+ export type TLayersOption = {
12
+ selectedTemplateId: string;
13
+ selectedShapeId: string;
14
+ selectedMaterialId: string;
15
+ selectedSize: TSize;
16
+ gridCm: boolean;
17
+ gridInch: boolean;
18
+ };
19
+
20
+ type HistoryState = {
21
+ layers: TLayer<LayrProps>[];
22
+ options: TLayersOption;
23
+ }
24
+
25
+ const CSS_INCH_IN_PX = 96;
26
+
27
+ const shiftTextLayersForSizeChange = ({
28
+ layers,
29
+ previousSize,
30
+ nextSize,
31
+ }: {
32
+ layers: TLayer<LayrProps>[];
33
+ previousSize: TSize;
34
+ nextSize: TSize;
35
+ }): TLayer<LayrProps>[] => {
36
+ const deltaX = ((nextSize.inchs.w - previousSize.inchs.w) * CSS_INCH_IN_PX) / 2;
37
+ const deltaY = ((nextSize.inchs.h - previousSize.inchs.h) * CSS_INCH_IN_PX) / 2;
38
+
39
+ if (Math.abs(deltaX) < 0.01 && Math.abs(deltaY) < 0.01) {
40
+ return layers;
41
+ }
42
+
43
+ return layers.map((layer) => {
44
+ if (layer.type !== "text") return layer;
45
+
46
+ const coordinates = layer.props.coordinates;
47
+ const x = coordinates?.x;
48
+ const y = coordinates?.y;
49
+
50
+ if (typeof x !== "number" || typeof y !== "number") {
51
+ return layer;
52
+ }
53
+
54
+ return {
55
+ ...layer,
56
+ props: {
57
+ ...layer.props,
58
+ coordinates: {
59
+ ...coordinates,
60
+ x: x + deltaX,
61
+ y: y + deltaY,
62
+ },
63
+ },
64
+ };
65
+ });
66
+ };
67
+
68
+ const templateDefaults = getDefaultTemplateOptions();
69
+
70
+ export const INITIAL_STATE = {
71
+ layers: [] as TLayer<LayrProps>[],
72
+ options: {
73
+ ...templateDefaults,
74
+ gridInch: false,
75
+ gridCm: false
76
+ } as TLayersOption,
77
+ resizing: false,
78
+ preview: undefined as string | undefined,
79
+ cartId: undefined as number | undefined,
80
+ textCenteringRequest: 0,
81
+
82
+ past: [] as HistoryState[],
83
+ future: [] as HistoryState[],
84
+ };
85
+
86
+ type LayersStore = {
87
+ layers: TLayer<LayrProps>[]
88
+ options: TLayersOption
89
+
90
+ past: HistoryState[]
91
+ future: HistoryState[]
92
+ undo: () => void
93
+ redo: () => void
94
+
95
+ setTemplate: (templateId: string) => void
96
+ setShape: (shapeId: string) => void
97
+ setSize: (sizeId: string) => void
98
+ setMaterial: (materialId: string) => void
99
+ editOptions: (value: Partial<TLayersOption>) => void
100
+ init: (layers: TLayer<LayrProps>[]) => void
101
+ reset: (layers: TLayer<LayrProps>[]) => void
102
+ resizing: boolean
103
+ setResizing: (resizing: boolean) => void
104
+ change: (index: number, props: Partial<LayrProps>) => void;
105
+ add: (layer: TLayer<LayrProps>) => void;
106
+ preview?: string;
107
+ setPreview: (base64: string) => void;
108
+ cartId?: number;
109
+ setCartId: (id: number | undefined) => void;
110
+ textCenteringRequest: number;
111
+ requestTextCentering: () => void;
112
+ remove: (index: number) => void;
113
+ clearAll: (state?: Partial<typeof INITIAL_STATE>) => void;
114
+ }
115
+
116
+ export const useLayersStore = create<LayersStore>()((set) => ({
117
+ ...INITIAL_STATE,
118
+
119
+ undo: () => set((state) => {
120
+ if (state.past.length === 0) return state;
121
+
122
+ const previous = state.past[state.past.length - 1];
123
+ const newPast = state.past.slice(0, -1);
124
+
125
+ return {
126
+ layers: previous.layers,
127
+ options: previous.options,
128
+ past: newPast,
129
+ future: [{ layers: state.layers, options: state.options }, ...state.future],
130
+ };
131
+ }),
132
+
133
+ redo: () => set((state) => {
134
+ if (state.future.length === 0) return state;
135
+
136
+ const next = state.future[0];
137
+ const newFuture = state.future.slice(1);
138
+
139
+ return {
140
+ layers: next.layers,
141
+ options: next.options,
142
+ past: [...state.past, { layers: state.layers, options: state.options }],
143
+ future: newFuture,
144
+ };
145
+ }),
146
+
147
+ init: (layers) => set({ layers, past: [], future: [] }),
148
+
149
+ reset: (layers) => set({ ...INITIAL_STATE, layers }),
150
+
151
+ clearAll: (state = INITIAL_STATE) => set(state),
152
+
153
+ setTemplate: (templateId) => set((state) => {
154
+ const resolved = resolveTemplateSelection({ templateId });
155
+ const nextOptions: TLayersOption = {
156
+ ...state.options,
157
+ selectedTemplateId: resolved.template.id,
158
+ selectedShapeId: resolved.shape.id,
159
+ selectedMaterialId: resolved.material.id,
160
+ selectedSize: resolved.size,
161
+ };
162
+
163
+ return {
164
+ past: [...state.past, { layers: state.layers, options: state.options }],
165
+ future: [],
166
+ options: nextOptions,
167
+ layers: createTemplateLayers({
168
+ templateId: resolved.template.id,
169
+ shapeId: resolved.shape.id,
170
+ materialId: resolved.material.id,
171
+ preferredSizeId: resolved.size.id,
172
+ }),
173
+ };
174
+ }),
175
+
176
+ setShape: (shapeId) => set((state) => {
177
+ const resolved = resolveTemplateSelection({
178
+ templateId: state.options.selectedTemplateId,
179
+ shapeId,
180
+ materialId: state.options.selectedMaterialId,
181
+ });
182
+
183
+ const nextOptions: TLayersOption = {
184
+ ...state.options,
185
+ selectedTemplateId: resolved.template.id,
186
+ selectedShapeId: resolved.shape.id,
187
+ selectedMaterialId: resolved.material.id,
188
+ selectedSize: resolved.size,
189
+ };
190
+
191
+ const nextLayers = state.layers.length
192
+ ? applyTemplateVisualsToLayers({
193
+ layers: state.layers,
194
+ templateId: resolved.template.id,
195
+ shapeId: resolved.shape.id,
196
+ materialId: resolved.material.id,
197
+ preferredSizeId: resolved.size.id,
198
+ })
199
+ : createTemplateLayers({
200
+ templateId: resolved.template.id,
201
+ shapeId: resolved.shape.id,
202
+ materialId: resolved.material.id,
203
+ preferredSizeId: resolved.size.id,
204
+ });
205
+
206
+ return {
207
+ past: [...state.past, { layers: state.layers, options: state.options }],
208
+ future: [],
209
+ options: nextOptions,
210
+ layers: nextLayers,
211
+ };
212
+ }),
213
+
214
+ setSize: (sizeId) => set((state) => {
215
+ const previousSize = state.options.selectedSize;
216
+ const resolved = resolveTemplateSelection({
217
+ templateId: state.options.selectedTemplateId,
218
+ materialId: state.options.selectedMaterialId,
219
+ preferredSizeId: sizeId,
220
+ });
221
+
222
+ const nextOptions: TLayersOption = {
223
+ ...state.options,
224
+ selectedTemplateId: resolved.template.id,
225
+ selectedShapeId: resolved.shape.id,
226
+ selectedMaterialId: resolved.material.id,
227
+ selectedSize: resolved.size,
228
+ };
229
+
230
+ const nextLayers = state.layers.length
231
+ ? shiftTextLayersForSizeChange({
232
+ previousSize,
233
+ nextSize: resolved.size,
234
+ layers: applyTemplateVisualsToLayers({
235
+ layers: state.layers,
236
+ templateId: resolved.template.id,
237
+ shapeId: resolved.shape.id,
238
+ materialId: resolved.material.id,
239
+ preferredSizeId: resolved.size.id,
240
+ }),
241
+ })
242
+ : createTemplateLayers({
243
+ templateId: resolved.template.id,
244
+ shapeId: resolved.shape.id,
245
+ materialId: resolved.material.id,
246
+ preferredSizeId: resolved.size.id,
247
+ });
248
+
249
+ return {
250
+ past: [...state.past, { layers: state.layers, options: state.options }],
251
+ future: [],
252
+ options: nextOptions,
253
+ layers: nextLayers,
254
+ };
255
+ }),
256
+
257
+ setMaterial: (materialId) => set((state) => {
258
+ const resolved = resolveTemplateSelection({
259
+ templateId: state.options.selectedTemplateId,
260
+ shapeId: state.options.selectedShapeId,
261
+ materialId,
262
+ preferredSizeId: state.options.selectedSize.id,
263
+ });
264
+
265
+ const nextOptions: TLayersOption = {
266
+ ...state.options,
267
+ selectedTemplateId: resolved.template.id,
268
+ selectedShapeId: resolved.shape.id,
269
+ selectedMaterialId: resolved.material.id,
270
+ selectedSize: resolved.size,
271
+ };
272
+
273
+ const nextLayers = state.layers.length
274
+ ? applyTemplateVisualsToLayers({
275
+ layers: state.layers,
276
+ templateId: resolved.template.id,
277
+ shapeId: resolved.shape.id,
278
+ materialId: resolved.material.id,
279
+ preferredSizeId: resolved.size.id,
280
+ })
281
+ : createTemplateLayers({
282
+ templateId: resolved.template.id,
283
+ shapeId: resolved.shape.id,
284
+ materialId: resolved.material.id,
285
+ preferredSizeId: resolved.size.id,
286
+ });
287
+
288
+ return {
289
+ past: [...state.past, { layers: state.layers, options: state.options }],
290
+ future: [],
291
+ options: nextOptions,
292
+ layers: nextLayers,
293
+ };
294
+ }),
295
+
296
+ setResizing: (resizing) => set({ resizing }),
297
+
298
+ change: (index, props) => set((state) => ({
299
+ past: [...state.past, { layers: state.layers, options: state.options }],
300
+ future: [],
301
+
302
+ layers: state.layers.map((layer, i) =>
303
+ i === index
304
+ ? { ...layer, props: { ...layer.props, ...props } }
305
+ : layer
306
+ )
307
+ })),
308
+
309
+ add: (layer) => set((state) => ({
310
+ past: [...state.past, { layers: state.layers, options: state.options }],
311
+ future: [],
312
+ layers: [...state.layers, layer]
313
+ })),
314
+
315
+ remove: (indexToRemove) => set((state) => ({
316
+ past: [...state.past, { layers: state.layers, options: state.options }],
317
+ future: [],
318
+ layers: state.layers.filter((_, index) => index !== indexToRemove)
319
+ })),
320
+
321
+ editOptions: (newOptions) => set((state) => ({
322
+ past: [...state.past, { layers: state.layers, options: state.options }],
323
+ future: [],
324
+ options: {
325
+ ...state.options,
326
+ ...newOptions
327
+ }
328
+ })),
329
+
330
+ setPreview: (base64) => set({ preview: base64 }),
331
+
332
+ setCartId: (id) => set({ cartId: id }),
333
+
334
+ requestTextCentering: () => set((state) => ({
335
+ textCenteringRequest: state.textCenteringRequest + 1,
336
+ })),
337
+ }))
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />