your personal website on atproto - mirror blento.app
at small-fixes 261 lines 7.5 kB view raw
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>