Something went wrong. Try again.
your personal website on atproto - mirror blento.app
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262<script lang="ts"> import { getStroke } from 'perfect-freehand'; import type { ContentComponentProps } from '../../types';
let { item = $bindable(), isEditing }: ContentComponentProps = $props();
type Stroke = { points: [number, number, number][]; size?: number; };
let currentStroke = $state<[number, number, number][]>([]); let isDrawing = $state(false); let svgElement: SVGSVGElement | undefined = $state();
const strokeSizes = [4, 8, 16] as const; let strokeWidth = $derived((item.cardData.strokeWidth as number) ?? 1);
function getStrokeOptions(size: number) { return { size, thinning: 0.5, smoothing: 0.5, streamline: 0.5 }; }
let isLocked = $derived(item.cardData?.locked ?? true);
function toggleLock() { item.cardData.locked = !item.cardData.locked; }
function setStrokeWidth(index: number) { item.cardData.strokeWidth = index; }
function getSvgPathFromStroke(stroke: number[][]): string { if (!stroke.length) return '';
const d = stroke.reduce( (acc, [x0, y0], i, arr) => { const [x1, y1] = arr[(i + 1) % arr.length]; acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); return acc; }, ['M', ...stroke[0], 'Q'] as (string | number)[] );
d.push('Z'); return d.join(' '); }
// Parse strokes from JSON string stored in cardData function parseStrokes(): Stroke[] { const strokesJson = item.cardData.strokesJson as string | undefined; if (!strokesJson) return []; try { return JSON.parse(strokesJson) as Stroke[]; } catch { return []; } }
// Save strokes as JSON string to cardData function saveStrokes(strokes: Stroke[]) { item.cardData.strokesJson = JSON.stringify(strokes); }
let strokes = $derived(parseStrokes()); let viewBox = $derived((item.cardData.viewBox as string) || '0 0 100 100');
function getPointerPosition(event: PointerEvent): [number, number, number] { if (!svgElement) return [0, 0, 0.5]; const rect = svgElement.getBoundingClientRect();
// Get the current viewBox dimensions const [, , vbWidth, vbHeight] = (item.cardData.viewBox as string)?.split(' ').map(Number) || [ 0, 0, 100, 100 ];
// Calculate the scale and offset for xMidYMid meet const scaleX = rect.width / vbWidth; const scaleY = rect.height / vbHeight; const scale = Math.min(scaleX, scaleY); // "meet" uses the smaller scale
// Calculate the actual rendered size of the viewBox content const renderedWidth = vbWidth * scale; const renderedHeight = vbHeight * scale;
// Calculate centering offsets (xMid, yMid) const offsetX = (rect.width - renderedWidth) / 2; const offsetY = (rect.height - renderedHeight) / 2;
// Map screen coordinates to viewBox coordinates, accounting for centering const x = ((event.clientX - rect.left - offsetX) / renderedWidth) * vbWidth; const y = ((event.clientY - rect.top - offsetY) / renderedHeight) * vbHeight; const pressure = event.pressure || 0.5; return [x, y, pressure]; }
function initViewBox() { if (!svgElement || item.cardData.viewBox) return; const rect = svgElement.getBoundingClientRect(); item.cardData.viewBox = `0 0 ${Math.round(rect.width)} ${Math.round(rect.height)}`; }
function handlePointerDown(event: PointerEvent) { isDrawing = true; initViewBox(); const point = getPointerPosition(event); currentStroke = [point]; (event.target as Element)?.setPointerCapture?.(event.pointerId); }
function handlePointerMove(event: PointerEvent) { if (!isDrawing) return; const point = getPointerPosition(event); currentStroke = [...currentStroke, point]; }
function handlePointerUp(event: PointerEvent) { if (!isDrawing) return; isDrawing = false; if (currentStroke.length > 0) { const newStroke: Stroke = { points: currentStroke, size: strokeSizes[strokeWidth] }; saveStrokes([...strokes, newStroke]); } currentStroke = []; (event.target as Element)?.releasePointerCapture?.(event.pointerId); }
function clearStrokes() { saveStrokes([]); item.cardData.viewBox = ''; }</script>
<div class={['absolute inset-0', isLocked ? 'touch-none' : '']}> <svg bind:this={svgElement} class={[ 'absolute inset-0 h-full w-full', isLocked ? 'pointer-events-auto cursor-crosshair' : 'pointer-events-none' ]} {viewBox} preserveAspectRatio="xMidYMid meet" onpointerdown={isLocked ? handlePointerDown : undefined} onpointermove={isLocked ? handlePointerMove : undefined} onpointerup={isLocked ? handlePointerUp : undefined} onpointerleave={isLocked ? handlePointerUp : undefined} > {#each strokes as stroke, index (index)} {@const pathData = getSvgPathFromStroke( getStroke(stroke.points, getStrokeOptions(stroke.size ?? 3)) )} <path d={pathData} class="accent:fill-white fill-black dark:fill-white" /> {/each} {#if currentStroke.length > 0} {@const pathData = getSvgPathFromStroke( getStroke(currentStroke, getStrokeOptions(strokeSizes[strokeWidth])) )} <path d={pathData} class="accent:fill-white fill-black dark:fill-white" /> {/if} </svg>
{#if !isLocked && strokes.length === 0} <div class="text-base-500 pointer-events-none absolute inset-0 flex items-center justify-center text-sm" > Lock to draw </div> {/if}
<div class="absolute top-2 right-2 flex gap-1"> {#if isLocked} <div class="bg-base-100/80 dark:bg-base-800/80 flex items-center gap-0.5 rounded-full px-1"> {#each strokeSizes as size, index (size)} <button type="button" class={[ 'flex items-center justify-center rounded-full p-1.5', strokeWidth === index ? 'bg-accent-500 text-white' : '' ]} onclick={() => setStrokeWidth(index)} aria-label={`Stroke size ${size}`} > <div class={[ 'rounded-full bg-current', index === 0 ? 'h-1.5 w-1.5' : index === 1 ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5' ]} ></div> </button> {/each} </div>
<button type="button" class="bg-base-100/80 dark:bg-base-800/80 rounded-full p-1.5" onclick={clearStrokes} aria-label="Clear drawing" > <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <polyline points="3 6 5 6 21 6"></polyline> <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" ></path> </svg> </button> {/if}
<button type="button" class={[ 'rounded-full p-1.5', isLocked ? 'bg-accent-500 text-white' : 'bg-base-100/80 text-base-900 dark:bg-base-800/80 dark:text-base-50' ]} onclick={toggleLock} aria-label={isLocked ? 'Unlock card' : 'Lock card to draw'} > {#if isLocked} <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect> <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> </svg> {:else} <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect> <path d="M7 11V7a5 5 0 0 1 9.9-1"></path> </svg> {/if} </button> </div></div>