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,259 @@
1
+ import { useLayersStore } from "../../store/layers-store"
2
+ import { Label } from "../ui/label"
3
+ import { Slider } from "../ui/slider"
4
+ import type { LayrProps } from "../preview"
5
+ import { isImageLayer, isTextLayer } from "../../lib/type-checks"
6
+ import { Textarea } from "../ui/textarea"
7
+ import { AlignCenterIcon, AlignJustifyIcon, AlignLeftIcon, AlignRightIcon, ImageIcon, TypeIcon } from "lucide-react"
8
+ import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"
9
+ import { useState } from "react"
10
+ import { Checkbox } from "../ui/checkbox"
11
+ import { Input } from "../ui/input"
12
+ import { getMaxLineLengthByPt } from "../../lib/config-font"
13
+
14
+ export default function LayersContainer() {
15
+ const { layers, init: setLayers, options } = useLayersStore()
16
+ const [customBraille, setCustomBraille] = useState(false)
17
+
18
+ const updateLayer = (index: number, props: Partial<LayrProps>) => {
19
+ const temp = layers
20
+ temp[index] = {
21
+ ...temp[index],
22
+ props: {
23
+ ...temp[index].props,
24
+ ...props,
25
+ },
26
+ }
27
+ setLayers(temp)
28
+ }
29
+
30
+ // Change image layer size
31
+ // const handleChangeLayerWidth = (index: number, newWidth: number) => {
32
+ // updateLayer(index, { width: `${newWidth}%` })
33
+ // }
34
+
35
+ const handleChangeTextFontScale = (index: number, scale: number) => {
36
+ updateLayer(index, { scale })
37
+ }
38
+
39
+ const handleChangeTextAlign = (index: number, align: "center" | "left" | "right" | "justify") => {
40
+ updateLayer(index, { align })
41
+ }
42
+
43
+ // Change layer content
44
+ const handleChangeLayerTextContent = (index: number, newContent: string) => {
45
+ updateLayer(index, { text: newContent })
46
+ if (layers[index].subtype !== "braille" && !customBraille) {
47
+ // Якщо браєль не кастомний, то встановлюється автоматично
48
+ const brailleLayerIndex = layers.findIndex((layer) => layer.subtype === "braille")
49
+ updateLayer(brailleLayerIndex, { text: newContent })
50
+ }
51
+ }
52
+
53
+ const getLongestLineLength = (text: string) => {
54
+ return text.split("\n").reduce((maxLength, line) => Math.max(maxLength, line.length), 0)
55
+ }
56
+
57
+ return (
58
+ <div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-xl border border-slate-200 shadow-lg p-6 space-y-6">
59
+ <div className="flex items-center gap-3 pb-4 border-b border-slate-200">
60
+ <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
61
+ <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
62
+ <path
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ strokeWidth={2}
66
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
67
+ />
68
+ </svg>
69
+ </div>
70
+ <h2 className="text-xl font-semibold text-slate-800 uppercase">Layers</h2>
71
+ <div className="ml-auto bg-slate-200 text-slate-600 text-xs px-2 py-1 rounded-full">
72
+ {layers.length} {layers.length === 1 ? "layer" : "layers"}
73
+ </div>
74
+ </div>
75
+
76
+ {layers.map((layer, index) => (
77
+ <div
78
+ key={`layer_container_${index}`}
79
+ className="bg-white rounded-lg border border-slate-200 shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden"
80
+ >
81
+ <div className="bg-slate-50 px-4 py-3 border-b border-slate-100 flex items-center gap-3">
82
+ {layer.type === "image" ? (
83
+ <ImageIcon className="w-4 h-4 text-primary" />
84
+ ) : (
85
+ <TypeIcon className="w-4 h-4 text-primary" />
86
+ )}
87
+ <span className="text-sm font-medium text-slate-700 capitalize">
88
+ {layer.type} Layer {layer.subtype === "braille" ? "(Braille)" : ""}
89
+ </span>
90
+ <div className="ml-auto text-xs text-slate-500">#{index + 1}</div>
91
+ </div>
92
+
93
+ <div className="p-4">
94
+ {layer.type === "image" && isImageLayer(layer) && (
95
+ <div className="flex flex-col gap-4">
96
+ <div className="flex items-center justify-center p-4 bg-slate-50 rounded-lg border-2 border-dashed border-slate-200">
97
+ <img
98
+ src={layer.props.imageSrc || "/placeholder.svg"}
99
+ className={`h-16 max-w-full object-contain rounded shadow-sm`}
100
+ alt="Layer preview"
101
+ />
102
+ </div>
103
+ </div>
104
+ )}
105
+
106
+ {layer.type === "text" && layer.subtype !== "braille" && isTextLayer(layer) && (
107
+ <div className="flex flex-col gap-6">
108
+ <div className="space-y-3">
109
+ <Label className="text-sm font-medium text-slate-700">Text Content</Label>
110
+ <Textarea
111
+ placeholder={"Kitchen ↵\nMy"}
112
+ onInput={(e) => handleChangeLayerTextContent(index, e.currentTarget.value)}
113
+ className="min-h-[80px] resize-none border-primary/50 focus:border-primary"
114
+ />
115
+ <p className={`text-xs ${getLongestLineLength(layer.props.text) > getMaxLineLengthByPt(layer.props.fontSize * (layer.props.scale ?? 1), options.selectedSize.inchs, options.selectedTemplateId) ? "text-red-700" : "text-[#8F8F8F]"}`}>
116
+ Recommended limit: {getMaxLineLengthByPt(layer.props.fontSize * (layer.props.scale ?? 1), options.selectedSize.inchs, options.selectedTemplateId)} chars per line (longest line: {getLongestLineLength(layer.props.text)})
117
+ </p>
118
+ {getLongestLineLength(layer.props.text) > getMaxLineLengthByPt(layer.props.fontSize * (layer.props.scale ?? 1), options.selectedSize.inchs, options.selectedTemplateId) && (
119
+ <div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
120
+ <svg
121
+ className="w-4 h-4 text-red-500 flex-shrink-0"
122
+ fill="none"
123
+ stroke="currentColor"
124
+ viewBox="0 0 24 24"
125
+ >
126
+ <path
127
+ strokeLinecap="round"
128
+ strokeLinejoin="round"
129
+ strokeWidth={2}
130
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
131
+ />
132
+ </svg>
133
+ <p className="text-xs text-red-700">
134
+ The maximum recommended text length is {getMaxLineLengthByPt(layer.props.fontSize * (layer.props.scale ?? 1), options.selectedSize.inchs, options.selectedTemplateId)} characters per line.
135
+ </p>
136
+ </div>
137
+ )}
138
+ </div>
139
+
140
+ <div className="space-y-3">
141
+ <Label className="text-sm font-medium text-slate-700">Text Alignment</Label>
142
+ <ToggleGroup
143
+ type="single"
144
+ onValueChange={(value) =>
145
+ handleChangeTextAlign(index, value as "center" | "left" | "right" | "justify")
146
+ }
147
+ className="justify-start bg-slate-50 p-1 rounded-lg"
148
+ >
149
+ <ToggleGroupItem
150
+ value="left"
151
+ aria-label="Align left"
152
+ className="data-[state=on]:bg-white data-[state=on]:shadow-sm"
153
+ >
154
+ <AlignLeftIcon className="h-4 w-4" />
155
+ </ToggleGroupItem>
156
+ <ToggleGroupItem
157
+ value="center"
158
+ aria-label="Align center"
159
+ className="data-[state=on]:bg-white data-[state=on]:shadow-sm"
160
+ >
161
+ <AlignCenterIcon className="h-4 w-4" />
162
+ </ToggleGroupItem>
163
+ <ToggleGroupItem
164
+ value="justify"
165
+ aria-label="Justify text"
166
+ className="data-[state=on]:bg-white data-[state=on]:shadow-sm"
167
+ >
168
+ <AlignJustifyIcon className="h-4 w-4" />
169
+ </ToggleGroupItem>
170
+ <ToggleGroupItem
171
+ value="right"
172
+ aria-label="Align right"
173
+ className="data-[state=on]:bg-white data-[state=on]:shadow-sm"
174
+ >
175
+ <AlignRightIcon className="h-4 w-4" />
176
+ </ToggleGroupItem>
177
+ </ToggleGroup>
178
+ </div>
179
+
180
+ <div className="space-y-3">
181
+ <Label className="text-sm font-medium text-slate-700">Font Size</Label>
182
+ <div className="px-3 py-2 bg-slate-50 rounded-lg">
183
+ <Slider
184
+ defaultValue={[1]}
185
+ min={0.5}
186
+ max={1.2}
187
+ step={0.05}
188
+ onValueChange={(value) => handleChangeTextFontScale(index, value[0])}
189
+ className="w-full"
190
+ />
191
+ <div className="flex justify-between text-xs text-slate-500 mt-1">
192
+ <span>Small</span>
193
+ <span>Large</span>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ )}
199
+
200
+ {layer.subtype === "braille" && isTextLayer(layer) && (
201
+ <div className="flex flex-col gap-6">
202
+ <div className="space-y-3">
203
+ <Label className="text-sm font-medium text-slate-700">Braille Preview</Label>
204
+ <div className="p-4 bg-slate-50 rounded-lg border border-slate-200 min-h-[60px] flex items-center">
205
+ <p className="font-braille text-lg text-slate-800">{layer.props.text}</p>
206
+ </div>
207
+ </div>
208
+
209
+ {customBraille && (
210
+ <div className="space-y-3">
211
+ <Label className="text-sm font-medium text-slate-700">Custom Braille Text</Label>
212
+ <Input
213
+ onInput={(e) => handleChangeLayerTextContent(index, e.currentTarget.value)}
214
+ placeholder="Doctor instead of Dr. Smith"
215
+ />
216
+ </div>
217
+ )}
218
+
219
+ <div className="flex items-center gap-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
220
+ <Checkbox
221
+ id={`custom-braille-${index}`}
222
+ checked={customBraille}
223
+ onCheckedChange={() => {
224
+ setCustomBraille((prev) => !prev)
225
+ }}
226
+ className="border-primary/20"
227
+ />
228
+ <Label
229
+ htmlFor={`custom-braille-${index}`}
230
+ className="text-sm font-medium cursor-pointer"
231
+ >
232
+ Enable Custom Braille
233
+ </Label>
234
+ </div>
235
+ </div>
236
+ )}
237
+ </div>
238
+ </div>
239
+ ))}
240
+
241
+ {layers.length === 0 && (
242
+ <div className="text-center py-12 text-slate-500">
243
+ <div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
244
+ <svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
245
+ <path
246
+ strokeLinecap="round"
247
+ strokeLinejoin="round"
248
+ strokeWidth={2}
249
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
250
+ />
251
+ </svg>
252
+ </div>
253
+ <p className="text-sm">No layers added yet</p>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
259
+
@@ -0,0 +1,128 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { TCoordinates, TMovableItemProps } from "../movable-item";
3
+ import type { TLayer } from "../preview";
4
+ import { useLayersStore } from "../../store/layers-store";
5
+ // import MovableItem from "../movable-item";
6
+ import { isLayerTextProps } from "../../lib/type-checks";
7
+ import { BASE_FONT_SIZE } from "../../lib/config-font";
8
+ import MovableItem from "../movable-item";
9
+
10
+ export type LayerTextProps = {
11
+ fontSize: number; // px
12
+ text: string;
13
+ scale?: number; // fontSize * scale = text size
14
+ color?: string;
15
+ // defaultCoordinatinates?: {
16
+ // top: '50%' | string;
17
+ // left: '50%' | string;
18
+ // };
19
+ align?: 'center' | 'left' | 'right' | 'justify';
20
+ braille?: boolean;
21
+ } & TMovableItemProps
22
+
23
+ export default function TextLayer({ layerIndex, font = 'font-bebasneue' }: {
24
+ layerIndex: number;
25
+ font?: 'font-bebasneue' | 'font-gothicb' | 'font-braille';
26
+ }) {
27
+ const [layer, setLayer] = useState<TLayer<LayerTextProps>>()
28
+ const { layers, init: updateLayers } = useLayersStore()
29
+ const [fontSize, setFontSize] = useState<number>();
30
+
31
+ const handleLayerUpdate = (coordinates: TCoordinates) => {
32
+ if (layers) {
33
+ const temp = layers;
34
+ temp[layerIndex] = {
35
+ ...temp[layerIndex],
36
+ props: {
37
+ ...temp[layerIndex].props, coordinates: {
38
+ x: (coordinates.x && coordinates.x > 0) ? coordinates.x : 0,
39
+ y: (coordinates.y && coordinates.y > 0) ? coordinates.y : 0,
40
+ }
41
+ }
42
+ }
43
+ updateLayers(temp)
44
+ }
45
+ }
46
+
47
+ useEffect(() => {
48
+ const layer = layers[layerIndex];
49
+
50
+ if (layers && isLayerTextProps(layer.props)) {
51
+ setLayer({
52
+ ...layer,
53
+ props: layer.props
54
+ });
55
+
56
+ // Вирахування розміру тексту
57
+ if (layer.subtype === 'braille') setFontSize(layer.props.fontSize)
58
+ // const longestLine = layer.props.text.split('\n').sort((a, b) => a.length - b.length);
59
+ calcFontSize() // longestLine[0].length
60
+
61
+ }
62
+ }, [layers[layerIndex]]);
63
+
64
+ // Вираховує розмір тексту
65
+ const calcFontSize = () => {
66
+ const layer = layers[layerIndex];
67
+ if (isLayerTextProps(layer.props) && layer.subtype !== 'braille') {
68
+ setFontSize(BASE_FONT_SIZE * (layer.props.scale ?? 1))
69
+ // let found = false; // Чи був знайдений відповідний розмір для довжини рядку
70
+ // const fontRange = Object.entries(FONT_RANGE);
71
+ // for (const [lengthRange, fontScale] of fontRange) {
72
+ // if (textLength < Number(lengthRange)) {
73
+ // const scale = (layer.props.scale ?? 1) < fontScale ? (layer.props.scale ?? 1) : fontScale;
74
+ // setFontSize(BASE_FONT_SIZE * scale);
75
+ // found = true;
76
+ // break;
77
+ // }
78
+ // }
79
+ // if (!found) {
80
+ // const [_, fontScale] = fontRange[fontRange.length - 1];
81
+ // const scale = (layer.props.scale ?? 1) < fontScale ? (layer.props.scale ?? 1) : fontScale;
82
+ // setFontSize(BASE_FONT_SIZE * scale);
83
+ // }
84
+ }
85
+ }
86
+
87
+ if (layer) {
88
+ const positionTransitionClass = layer.props.movable
89
+ ? ""
90
+ : "transition-[top,left] duration-500 ease-in-out";
91
+
92
+ const layerJsx = (
93
+ <div className={`absolute -translate-x-1/2 -translate-y-1/2 ${positionTransitionClass} ${font} select-none`} style={{
94
+ fontSize: `${(fontSize ?? 20) * (layer.subtype === 'braille' ? (layer.props.scale ?? 1) : 1)}pt`,
95
+ top: layer.props.coordinates.y,
96
+ left: layer.props.coordinates.x,
97
+ color: layer.props.color ?? "#111111",
98
+ }}>
99
+ {layer.subtype === 'braille' ? (
100
+ <p
101
+ className='overflow-hidden text-nowrap transition'
102
+ draggable={false}
103
+ style={{
104
+ textAlign: layer.props.align ?? 'center'
105
+ }}
106
+ >{layer.props.text}</p>
107
+ ) : layer.props.text.split('\n').map((paragraph, index) => (
108
+ <p
109
+ key={`${paragraph} ${index}`}
110
+ className='overflow-hidden text-nowrap'
111
+ draggable={false}
112
+ style={{
113
+ lineHeight: 1,
114
+ textAlign: layer.props.align ?? 'center'
115
+ }}
116
+ >{paragraph}</p>
117
+ ))}
118
+ </div>
119
+ );
120
+
121
+ return layer.props.movable ? (
122
+ <MovableItem coordinates={layer.props.coordinates} onUpdate={handleLayerUpdate}>
123
+ {layerJsx}
124
+ </MovableItem>
125
+ ) : layerJsx
126
+
127
+ }
128
+ }
@@ -0,0 +1,228 @@
1
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { useLayersStore } from "../store/layers-store";
3
+
4
+ export type TCoordinates = {
5
+ x?: number;
6
+ y?: number;
7
+ };
8
+
9
+ export type TMovableItemProps = {
10
+ coordinates: TCoordinates;
11
+ movable?: boolean;
12
+ };
13
+
14
+ const SNAP_THRESHOLD = 15;
15
+
16
+ const clamp = (value: number, min: number, max: number) => {
17
+ if (Number.isNaN(value)) return min;
18
+ return Math.min(Math.max(value, min), max);
19
+ };
20
+
21
+ export default function MovableItem({ coordinates, onUpdate, children }: {
22
+ coordinates: TCoordinates;
23
+ onUpdate: (coordinates: TCoordinates) => void;
24
+ children: ReactNode;
25
+ }) {
26
+ const [isDragging, setIsDragging] = useState(false);
27
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
28
+ const [initialPosition, setInitialPosition] = useState({ x: 0, y: 0 });
29
+
30
+ const [snapX, setSnapX] = useState(false);
31
+ const [snapY, setSnapY] = useState(false);
32
+
33
+ const activePointerIdRef = useRef<number | null>(null);
34
+ const containerRef = useRef<HTMLDivElement>(null);
35
+ const bodyStyleBackupRef = useRef<{
36
+ userSelect: string;
37
+ touchAction: string;
38
+ overflow: string;
39
+ } | null>(null);
40
+
41
+ const { resizing } = useLayersStore();
42
+
43
+ const lockPageInteraction = useCallback(() => {
44
+ if (bodyStyleBackupRef.current) return;
45
+
46
+ bodyStyleBackupRef.current = {
47
+ userSelect: document.body.style.userSelect,
48
+ touchAction: document.body.style.touchAction,
49
+ overflow: document.body.style.overflow,
50
+ };
51
+
52
+ document.body.style.userSelect = "none";
53
+ document.body.style.touchAction = "none";
54
+ document.body.style.overflow = "hidden";
55
+ }, []);
56
+
57
+ const restorePageInteraction = useCallback(() => {
58
+ if (!bodyStyleBackupRef.current) return;
59
+
60
+ document.body.style.userSelect = bodyStyleBackupRef.current.userSelect;
61
+ document.body.style.touchAction = bodyStyleBackupRef.current.touchAction;
62
+ document.body.style.overflow = bodyStyleBackupRef.current.overflow;
63
+
64
+ bodyStyleBackupRef.current = null;
65
+ }, []);
66
+
67
+ const updateByPointerPosition = useCallback(
68
+ (clientX: number, clientY: number) => {
69
+ if (!isDragging || !coordinates || !onUpdate) return;
70
+
71
+ const parent = containerRef.current?.offsetParent as HTMLElement | null;
72
+ const parentRect = parent?.getBoundingClientRect();
73
+
74
+ const scaleX = parent && parent.clientWidth ? (parentRect?.width ? parentRect.width / parent.clientWidth : 1) : 1;
75
+ const scaleY = parent && parent.clientHeight ? (parentRect?.height ? parentRect.height / parent.clientHeight : 1) : 1;
76
+
77
+ const deltaX = (clientX - dragStart.x) / (scaleX || 1);
78
+ const deltaY = (clientY - dragStart.y) / (scaleY || 1);
79
+
80
+ let newX = initialPosition.x + deltaX;
81
+ let newY = initialPosition.y + deltaY;
82
+ let isSnappedX = false;
83
+ let isSnappedY = false;
84
+
85
+ if (parent) {
86
+ const parentWidth = parent.clientWidth;
87
+ const parentHeight = parent.clientHeight;
88
+ const centerX = parentWidth / 2;
89
+ const centerY = parentHeight / 2;
90
+
91
+ if (Math.abs(newX - centerX) < SNAP_THRESHOLD) {
92
+ newX = centerX;
93
+ isSnappedX = true;
94
+ }
95
+
96
+ if (Math.abs(newY - centerY) < SNAP_THRESHOLD) {
97
+ newY = centerY;
98
+ isSnappedY = true;
99
+ }
100
+
101
+ newX = clamp(newX, 0, parentWidth);
102
+ newY = clamp(newY, 0, parentHeight);
103
+ }
104
+
105
+ setSnapX(isSnappedX);
106
+ setSnapY(isSnappedY);
107
+
108
+ if (!resizing) {
109
+ onUpdate({ x: newX, y: newY });
110
+ }
111
+ },
112
+ [coordinates, dragStart.x, dragStart.y, initialPosition.x, initialPosition.y, isDragging, onUpdate, resizing],
113
+ );
114
+
115
+ const finishDragging = useCallback(() => {
116
+ setIsDragging(false);
117
+ setSnapX(false);
118
+ setSnapY(false);
119
+ activePointerIdRef.current = null;
120
+ restorePageInteraction();
121
+ }, [restorePageInteraction]);
122
+
123
+ const handlePointerDown = useCallback(
124
+ (e: React.PointerEvent<HTMLDivElement>) => {
125
+ if (!coordinates || !onUpdate) return;
126
+ if (e.pointerType === "mouse" && e.button !== 0) return;
127
+
128
+ e.preventDefault();
129
+ e.stopPropagation();
130
+
131
+ activePointerIdRef.current = e.pointerId;
132
+
133
+ if (e.currentTarget.setPointerCapture) {
134
+ e.currentTarget.setPointerCapture(e.pointerId);
135
+ }
136
+
137
+ setIsDragging(true);
138
+ setDragStart({ x: e.clientX, y: e.clientY });
139
+ setInitialPosition({
140
+ x: coordinates?.x || 0,
141
+ y: coordinates?.y || 0,
142
+ });
143
+
144
+ lockPageInteraction();
145
+ },
146
+ [coordinates, lockPageInteraction, onUpdate],
147
+ );
148
+
149
+ const handleGlobalPointerMove = useCallback(
150
+ (e: PointerEvent) => {
151
+ if (!isDragging) return;
152
+ if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return;
153
+
154
+ e.preventDefault();
155
+ updateByPointerPosition(e.clientX, e.clientY);
156
+ },
157
+ [isDragging, updateByPointerPosition],
158
+ );
159
+
160
+ const handleGlobalPointerUp = useCallback(
161
+ (e: PointerEvent) => {
162
+ if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current) return;
163
+ finishDragging();
164
+ },
165
+ [finishDragging],
166
+ );
167
+
168
+ useEffect(() => {
169
+ if (!isDragging) return;
170
+
171
+ document.addEventListener("pointermove", handleGlobalPointerMove, { passive: false });
172
+ document.addEventListener("pointerup", handleGlobalPointerUp);
173
+ document.addEventListener("pointercancel", handleGlobalPointerUp);
174
+ window.addEventListener("blur", finishDragging);
175
+
176
+ return () => {
177
+ document.removeEventListener("pointermove", handleGlobalPointerMove);
178
+ document.removeEventListener("pointerup", handleGlobalPointerUp);
179
+ document.removeEventListener("pointercancel", handleGlobalPointerUp);
180
+ window.removeEventListener("blur", finishDragging);
181
+ restorePageInteraction();
182
+ };
183
+ }, [finishDragging, handleGlobalPointerMove, handleGlobalPointerUp, isDragging, restorePageInteraction]);
184
+
185
+ const offsetParent = containerRef.current?.offsetParent as HTMLElement | null;
186
+ const offsetParentWidth = offsetParent?.clientWidth ?? 0;
187
+ const offsetParentHeight = offsetParent?.clientHeight ?? 0;
188
+
189
+ return (
190
+ <div
191
+ ref={containerRef}
192
+ className={`${isDragging ? "cursor-grabbing" : "cursor-grab"} select-none`}
193
+ >
194
+ <div
195
+ onPointerDown={handlePointerDown}
196
+ className="touch-none hover:[&>*]:outline-2 hover:[&>*]:outline-blue-600 active:[&>*]:outline-2 active:[&>*]:outline-blue-600"
197
+ >
198
+ {children}
199
+ </div>
200
+
201
+ {isDragging && (
202
+ <div className="absolute top-2 left-2 z-50 rounded bg-blue-500 px-2 py-1 text-xs whitespace-nowrap text-white pointer-events-none">
203
+ X: {coordinates?.x?.toFixed(0)}, Y: {coordinates?.y?.toFixed(0)}
204
+ </div>
205
+ )}
206
+
207
+ {snapX && (
208
+ <div
209
+ className="absolute top-0 bottom-0 border-l border-dashed border-red-500 pointer-events-none"
210
+ style={{
211
+ left: offsetParentWidth / 2,
212
+ zIndex: 60,
213
+ }}
214
+ />
215
+ )}
216
+
217
+ {snapY && (
218
+ <div
219
+ className="absolute left-0 right-0 border-t border-dashed border-red-500 pointer-events-none"
220
+ style={{
221
+ top: offsetParentHeight / 2,
222
+ zIndex: 60,
223
+ }}
224
+ />
225
+ )}
226
+ </div>
227
+ );
228
+ }