your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { getStroke } from 'perfect-freehand';
3 import type { ContentComponentProps } from '../types';
4
5 let { item = $bindable(), isEditing }: ContentComponentProps = $props();
6
7 type Stroke = {
8 points: [number, number, number][];
9 size?: number;
10 };
11
12 let currentStroke = $state<[number, number, number][]>([]);
13 let isDrawing = $state(false);
14 let svgElement: SVGSVGElement | undefined = $state();
15
16 const strokeSizes = [4, 8, 16] as const;
17 let strokeWidth = $derived((item.cardData.strokeWidth as number) ?? 1);
18
19 function getStrokeOptions(size: number) {
20 return { size, thinning: 0.5, smoothing: 0.5, streamline: 0.5 };
21 }
22
23 let isLocked = $derived(item.cardData?.locked ?? true);
24
25 function toggleLock() {
26 item.cardData.locked = !item.cardData.locked;
27 }
28
29 function setStrokeWidth(index: number) {
30 item.cardData.strokeWidth = index;
31 }
32
33 function getSvgPathFromStroke(stroke: number[][]): string {
34 if (!stroke.length) return '';
35
36 const d = stroke.reduce(
37 (acc, [x0, y0], i, arr) => {
38 const [x1, y1] = arr[(i + 1) % arr.length];
39 acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
40 return acc;
41 },
42 ['M', ...stroke[0], 'Q'] as (string | number)[]
43 );
44
45 d.push('Z');
46 return d.join(' ');
47 }
48
49 // Parse strokes from JSON string stored in cardData
50 function parseStrokes(): Stroke[] {
51 const strokesJson = item.cardData.strokesJson as string | undefined;
52 if (!strokesJson) return [];
53 try {
54 return JSON.parse(strokesJson) as Stroke[];
55 } catch {
56 return [];
57 }
58 }
59
60 // Save strokes as JSON string to cardData
61 function saveStrokes(strokes: Stroke[]) {
62 item.cardData.strokesJson = JSON.stringify(strokes);
63 }
64
65 let strokes = $derived(parseStrokes());
66 let viewBox = $derived((item.cardData.viewBox as string) || '0 0 100 100');
67
68 function getPointerPosition(event: PointerEvent): [number, number, number] {
69 if (!svgElement) return [0, 0, 0.5];
70 const rect = svgElement.getBoundingClientRect();
71
72 // Get the current viewBox dimensions
73 const [, , vbWidth, vbHeight] = (item.cardData.viewBox as string)?.split(' ').map(Number) || [
74 0, 0, 100, 100
75 ];
76
77 // Calculate the scale and offset for xMidYMid meet
78 const scaleX = rect.width / vbWidth;
79 const scaleY = rect.height / vbHeight;
80 const scale = Math.min(scaleX, scaleY); // "meet" uses the smaller scale
81
82 // Calculate the actual rendered size of the viewBox content
83 const renderedWidth = vbWidth * scale;
84 const renderedHeight = vbHeight * scale;
85
86 // Calculate centering offsets (xMid, yMid)
87 const offsetX = (rect.width - renderedWidth) / 2;
88 const offsetY = (rect.height - renderedHeight) / 2;
89
90 // Map screen coordinates to viewBox coordinates, accounting for centering
91 const x = ((event.clientX - rect.left - offsetX) / renderedWidth) * vbWidth;
92 const y = ((event.clientY - rect.top - offsetY) / renderedHeight) * vbHeight;
93 const pressure = event.pressure || 0.5;
94 return [x, y, pressure];
95 }
96
97 function initViewBox() {
98 if (!svgElement || item.cardData.viewBox) return;
99 const rect = svgElement.getBoundingClientRect();
100 item.cardData.viewBox = `0 0 ${Math.round(rect.width)} ${Math.round(rect.height)}`;
101 }
102
103 function handlePointerDown(event: PointerEvent) {
104 isDrawing = true;
105 initViewBox();
106 const point = getPointerPosition(event);
107 currentStroke = [point];
108 (event.target as Element)?.setPointerCapture?.(event.pointerId);
109 }
110
111 function handlePointerMove(event: PointerEvent) {
112 if (!isDrawing) return;
113 const point = getPointerPosition(event);
114 currentStroke = [...currentStroke, point];
115 }
116
117 function handlePointerUp(event: PointerEvent) {
118 if (!isDrawing) return;
119 isDrawing = false;
120 if (currentStroke.length > 0) {
121 const newStroke: Stroke = {
122 points: currentStroke,
123 size: strokeSizes[strokeWidth]
124 };
125 saveStrokes([...strokes, newStroke]);
126 }
127 currentStroke = [];
128 (event.target as Element)?.releasePointerCapture?.(event.pointerId);
129 }
130
131 function clearStrokes() {
132 saveStrokes([]);
133 item.cardData.viewBox = '';
134 }
135</script>
136
137<div class={['absolute inset-0', isLocked ? 'touch-none' : '']}>
138 <svg
139 bind:this={svgElement}
140 class={[
141 'absolute inset-0 h-full w-full',
142 isLocked ? 'pointer-events-auto cursor-crosshair' : 'pointer-events-none'
143 ]}
144 {viewBox}
145 preserveAspectRatio="xMidYMid meet"
146 onpointerdown={isLocked ? handlePointerDown : undefined}
147 onpointermove={isLocked ? handlePointerMove : undefined}
148 onpointerup={isLocked ? handlePointerUp : undefined}
149 onpointerleave={isLocked ? handlePointerUp : undefined}
150 >
151 {#each strokes as stroke, index (index)}
152 {@const pathData = getSvgPathFromStroke(
153 getStroke(stroke.points, getStrokeOptions(stroke.size ?? 3))
154 )}
155 <path d={pathData} class="accent:fill-white fill-black dark:fill-white" />
156 {/each}
157 {#if currentStroke.length > 0}
158 {@const pathData = getSvgPathFromStroke(
159 getStroke(currentStroke, getStrokeOptions(strokeSizes[strokeWidth]))
160 )}
161 <path d={pathData} class="accent:fill-white fill-black dark:fill-white" />
162 {/if}
163 </svg>
164
165 {#if !isLocked && strokes.length === 0}
166 <div
167 class="text-base-500 pointer-events-none absolute inset-0 flex items-center justify-center text-sm"
168 >
169 Lock to draw
170 </div>
171 {/if}
172
173 <div class="absolute top-2 right-2 flex gap-1">
174 {#if isLocked}
175 <div class="bg-base-100/80 dark:bg-base-800/80 flex items-center gap-0.5 rounded-full px-1">
176 {#each strokeSizes as size, index (size)}
177 <button
178 type="button"
179 class={[
180 'flex items-center justify-center rounded-full p-1.5',
181 strokeWidth === index ? 'bg-accent-500 text-white' : ''
182 ]}
183 onclick={() => setStrokeWidth(index)}
184 aria-label={`Stroke size ${size}`}
185 >
186 <div
187 class={[
188 'rounded-full bg-current',
189 index === 0 ? 'h-1.5 w-1.5' : index === 1 ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'
190 ]}
191 ></div>
192 </button>
193 {/each}
194 </div>
195
196 <button
197 type="button"
198 class="bg-base-100/80 dark:bg-base-800/80 rounded-full p-1.5"
199 onclick={clearStrokes}
200 aria-label="Clear drawing"
201 >
202 <svg
203 xmlns="http://www.w3.org/2000/svg"
204 class="h-4 w-4"
205 viewBox="0 0 24 24"
206 fill="none"
207 stroke="currentColor"
208 stroke-width="2"
209 stroke-linecap="round"
210 stroke-linejoin="round"
211 >
212 <polyline points="3 6 5 6 21 6"></polyline>
213 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
214 ></path>
215 </svg>
216 </button>
217 {/if}
218
219 <button
220 type="button"
221 class={[
222 'rounded-full p-1.5',
223 isLocked
224 ? 'bg-accent-500 text-white'
225 : 'bg-base-100/80 text-base-900 dark:bg-base-800/80 dark:text-base-50'
226 ]}
227 onclick={toggleLock}
228 aria-label={isLocked ? 'Unlock card' : 'Lock card to draw'}
229 >
230 {#if isLocked}
231 <svg
232 xmlns="http://www.w3.org/2000/svg"
233 class="h-4 w-4"
234 viewBox="0 0 24 24"
235 fill="none"
236 stroke="currentColor"
237 stroke-width="2"
238 stroke-linecap="round"
239 stroke-linejoin="round"
240 >
241 <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
242 <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
243 </svg>
244 {:else}
245 <svg
246 xmlns="http://www.w3.org/2000/svg"
247 class="h-4 w-4"
248 viewBox="0 0 24 24"
249 fill="none"
250 stroke="currentColor"
251 stroke-width="2"
252 stroke-linecap="round"
253 stroke-linejoin="round"
254 >
255 <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
256 <path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
257 </svg>
258 {/if}
259 </button>
260 </div>
261</div>