atprotocol stickers
at main 524 lines 13 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte'; 3 4 interface Props { 5 file: File; 6 onDone: (result: { 7 imageBlob: Blob; 8 maskPath: string; 9 imageWidth: number; 10 imageHeight: number; 11 borderColor: string; 12 borderThickness: number; 13 shortname: string; 14 }) => void; 15 onCancel: () => void; 16 } 17 18 let { file, onDone, onCancel }: Props = $props(); 19 20 // Form state 21 let shortname = $state(''); 22 let borderColor = $state('#ff99cc'); 23 let borderThickness = $state(8); 24 let borderEnabled = $state(false); 25 let processing = $state(false); 26 27 // Canvas + image 28 let canvas: HTMLCanvasElement; 29 let img: HTMLImageElement; 30 let imgLoaded = $state(false); 31 let displayW = 0; 32 let displayH = 0; 33 let scaleX = 1; 34 let scaleY = 1; 35 36 // Drawing state 37 type Point = { x: number; y: number }; 38 let points = $state<Point[]>([]); 39 let isDrawing = $state(false); 40 let pathClosed = $state(false); 41 const CLOSE_RADIUS = 14; // px — snap-close distance 42 43 onMount(() => { 44 shortname = file.name.replace(/\.[^.]+$/, '').slice(0, 50); 45 img = new Image(); 46 img.onload = () => { 47 imgLoaded = true; 48 // Fit image into max 520×420 display area 49 const maxW = 520; 50 const maxH = 420; 51 const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1); 52 displayW = Math.round(img.naturalWidth * ratio); 53 displayH = Math.round(img.naturalHeight * ratio); 54 scaleX = img.naturalWidth / displayW; 55 scaleY = img.naturalHeight / displayH; 56 canvas.width = displayW; 57 canvas.height = displayH; 58 redraw(); 59 }; 60 img.src = URL.createObjectURL(file); 61 return () => URL.revokeObjectURL(img.src); 62 }); 63 64 $effect(() => { 65 // Redraw preview when border settings change 66 const _deps = [borderEnabled, borderColor, borderThickness, pathClosed, points.length]; 67 if (imgLoaded) redraw(); 68 }); 69 70 function redraw() { 71 if (!canvas || !img) return; 72 const ctx = canvas.getContext('2d')!; 73 ctx.clearRect(0, 0, displayW, displayH); 74 75 // Draw image dimmed 76 ctx.globalAlpha = pathClosed ? 0.35 : 0.75; 77 ctx.drawImage(img, 0, 0, displayW, displayH); 78 ctx.globalAlpha = 1; 79 80 if (points.length < 2) return; 81 82 // Draw the current/closed path 83 ctx.beginPath(); 84 ctx.moveTo(points[0].x, points[0].y); 85 for (let i = 1; i < points.length; i++) { 86 const prev = points[i - 1]; 87 const curr = points[i]; 88 // Smooth with midpoints 89 ctx.quadraticCurveTo(prev.x, prev.y, (prev.x + curr.x) / 2, (prev.y + curr.y) / 2); 90 } 91 if (pathClosed) ctx.closePath(); 92 93 // If closed: border outside, then image clipped inside 94 if (pathClosed) { 95 // Border first (before clip) so it sits outside the image 96 if (borderEnabled && borderThickness > 0) { 97 ctx.strokeStyle = borderColor; 98 ctx.lineWidth = borderThickness * 2; 99 ctx.lineJoin = 'round'; 100 ctx.lineCap = 'round'; 101 ctx.stroke(); 102 } 103 // Clip and draw image on top, covering the inner half of the stroke 104 ctx.save(); 105 ctx.clip(); 106 ctx.globalAlpha = 1; 107 ctx.drawImage(img, 0, 0, displayW, displayH); 108 ctx.restore(); 109 } 110 111 // Draw path outline 112 ctx.strokeStyle = pathClosed ? 'rgba(255,255,255,0.6)' : '#ffffff'; 113 ctx.lineWidth = pathClosed ? 1 : 2; 114 ctx.setLineDash(pathClosed ? [] : [6, 3]); 115 ctx.lineJoin = 'round'; 116 ctx.lineCap = 'round'; 117 ctx.stroke(); 118 ctx.setLineDash([]); 119 120 // Close-snap indicator when near start 121 if (!pathClosed && points.length > 3) { 122 ctx.beginPath(); 123 ctx.arc(points[0].x, points[0].y, CLOSE_RADIUS, 0, Math.PI * 2); 124 ctx.strokeStyle = 'rgba(255,255,100,0.7)'; 125 ctx.lineWidth = 1.5; 126 ctx.stroke(); 127 } 128 } 129 130 function getPos(e: MouseEvent | TouchEvent): Point { 131 const rect = canvas.getBoundingClientRect(); 132 const src = e instanceof TouchEvent ? e.touches[0] : e; 133 return { 134 x: (src.clientX - rect.left) * (displayW / rect.width), 135 y: (src.clientY - rect.top) * (displayH / rect.height) 136 }; 137 } 138 139 function isNearStart(p: Point): boolean { 140 if (points.length < 4) return false; 141 const dx = p.x - points[0].x; 142 const dy = p.y - points[0].y; 143 return Math.sqrt(dx * dx + dy * dy) < CLOSE_RADIUS; 144 } 145 146 function startDraw(e: MouseEvent | TouchEvent) { 147 e.preventDefault(); 148 if (pathClosed) return; 149 isDrawing = true; 150 const p = getPos(e); 151 points = [p]; 152 } 153 154 function continueDraw(e: MouseEvent | TouchEvent) { 155 e.preventDefault(); 156 if (!isDrawing || pathClosed) return; 157 const p = getPos(e); 158 // Sub-sample: only add point if moved enough 159 const last = points[points.length - 1]; 160 const dx = p.x - last.x; 161 const dy = p.y - last.y; 162 if (dx * dx + dy * dy < 9) return; 163 points = [...points, p]; 164 redraw(); 165 } 166 167 function endDraw(e: MouseEvent | TouchEvent) { 168 e.preventDefault(); 169 if (!isDrawing) return; 170 isDrawing = false; 171 const p = getPos(e); 172 if (isNearStart(p) || points.length > 8) { 173 pathClosed = true; 174 } 175 redraw(); 176 } 177 178 function closePath() { 179 if (points.length > 2) { 180 pathClosed = true; 181 redraw(); 182 } 183 } 184 185 function resetPath() { 186 points = []; 187 pathClosed = false; 188 redraw(); 189 } 190 191 async function buildImageAndPath(): Promise<{ 192 imageBlob: Blob; 193 maskPath: string; 194 imageWidth: number; 195 imageHeight: number; 196 }> { 197 // Bounding box of drawn path in display pixels 198 const xs = points.map((p) => p.x); 199 const ys = points.map((p) => p.y); 200 const bboxX = Math.max(0, Math.floor(Math.min(...xs))); 201 const bboxY = Math.max(0, Math.floor(Math.min(...ys))); 202 const bboxMaxX = Math.min(displayW, Math.ceil(Math.max(...xs))); 203 const bboxMaxY = Math.min(displayH, Math.ceil(Math.max(...ys))); 204 205 // Scale to natural image pixel crop region 206 const cropX = Math.round(bboxX * scaleX); 207 const cropY = Math.round(bboxY * scaleY); 208 const cropW = Math.round((bboxMaxX - bboxX) * scaleX); 209 const cropH = Math.round((bboxMaxY - bboxY) * scaleY); 210 211 // Build SVG path in natural image pixels, relative to crop origin 212 const sx = (x: number) => Math.round((x - bboxX) * scaleX); 213 const sy = (y: number) => Math.round((y - bboxY) * scaleY); 214 let d = `M ${sx(points[0].x)} ${sy(points[0].y)}`; 215 for (let i = 1; i < points.length; i++) { 216 const prev = points[i - 1]; 217 const curr = points[i]; 218 d += ` Q ${sx(prev.x)} ${sy(prev.y)} ${sx((prev.x + curr.x) / 2)} ${sy((prev.y + curr.y) / 2)}`; 219 } 220 d += ' Z'; 221 222 // Crop to bbox, then mask out pixels outside the drawn path using destination-in compositing. 223 // Exported as PNG to preserve transparency. 224 const out = document.createElement('canvas'); 225 out.width = cropW; 226 out.height = cropH; 227 const outCtx = out.getContext('2d')!; 228 outCtx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); 229 230 // Keep only pixels inside the mask path; clear everything outside 231 outCtx.globalCompositeOperation = 'destination-in'; 232 outCtx.fill(new Path2D(d)); 233 outCtx.globalCompositeOperation = 'source-over'; 234 235 const imageBlob = await new Promise<Blob>((resolve, reject) => 236 out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png') 237 ); 238 239 return { imageBlob, maskPath: d, imageWidth: cropW, imageHeight: cropH }; 240 } 241 242 async function handleSubmit() { 243 if (!shortname.trim() || !pathClosed) return; 244 processing = true; 245 try { 246 const { imageBlob, maskPath, imageWidth, imageHeight } = await buildImageAndPath(); 247 onDone({ 248 imageBlob, 249 maskPath, 250 imageWidth, 251 imageHeight, 252 borderColor: borderEnabled ? borderColor : '', 253 // Store border thickness in natural image pixels so SVG can use directly 254 borderThickness: borderEnabled ? Math.round(borderThickness * scaleX) : 0, 255 shortname: shortname.trim() 256 }); 257 } finally { 258 processing = false; 259 } 260 } 261</script> 262 263<div class="editor"> 264 <h2>create sticker</h2> 265 266 <div class="editor-body"> 267 <!-- Drawing canvas --> 268 <div class="canvas-wrap"> 269 {#if !imgLoaded} 270 <div class="canvas-placeholder">loading…</div> 271 {/if} 272 <canvas 273 bind:this={canvas} 274 class:hidden={!imgLoaded} 275 onmousedown={startDraw} 276 onmousemove={continueDraw} 277 onmouseup={endDraw} 278 onmouseleave={endDraw} 279 ontouchstart={startDraw} 280 ontouchmove={continueDraw} 281 ontouchend={endDraw} 282 ></canvas> 283 <div class="canvas-hint"> 284 {#if !imgLoaded} 285 &nbsp; 286 {:else if pathClosed} 287 ✓ shape drawn 288 {:else if points.length > 3} 289 draw back to the dot to close, or click "close shape" 290 {:else} 291 click and drag to draw your sticker shape 292 {/if} 293 </div> 294 </div> 295 296 <!-- Controls --> 297 <div class="controls"> 298 <label class="field"> 299 <span>shortname</span> 300 <input type="text" bind:value={shortname} maxlength="50" placeholder="my cool sticker" /> 301 </label> 302 303 <label class="field checkbox-field"> 304 <input type="checkbox" bind:checked={borderEnabled} /> 305 <span>add border</span> 306 </label> 307 308 {#if borderEnabled} 309 <label class="field"> 310 <span>border color</span> 311 <div class="color-row"> 312 <input type="color" bind:value={borderColor} /> 313 <span class="hex">{borderColor}</span> 314 </div> 315 </label> 316 <label class="field"> 317 <span>thickness: {borderThickness}px</span> 318 <input type="range" min="1" max="30" bind:value={borderThickness} /> 319 </label> 320 {/if} 321 322 <div class="path-actions"> 323 {#if !pathClosed && points.length > 2} 324 <button class="btn-outline" onclick={closePath}>close shape</button> 325 {/if} 326 {#if points.length > 0} 327 <button class="btn-outline" onclick={resetPath}>redraw</button> 328 {/if} 329 </div> 330 331 <div class="actions"> 332 <button class="btn-cancel" onclick={onCancel}>cancel</button> 333 <button 334 class="btn-submit" 335 onclick={handleSubmit} 336 disabled={processing || !shortname.trim() || !pathClosed} 337 > 338 {processing ? 'processing…' : 'use this sticker →'} 339 </button> 340 </div> 341 </div> 342 </div> 343</div> 344 345<style> 346 .editor { 347 background: #fffef8; 348 border: 2px solid #e0d4c0; 349 border-radius: 2px; 350 padding: 1.5rem; 351 box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.12); 352 max-width: 900px; 353 margin: 0 auto; 354 } 355 356 h2 { 357 font-family: 'Caveat', cursive; 358 font-size: 2rem; 359 margin: 0 0 1.2rem; 360 color: #5a4a6a; 361 transform: rotate(-0.5deg); 362 } 363 364 .editor-body { 365 display: grid; 366 grid-template-columns: 1fr auto; 367 gap: 1.5rem; 368 align-items: start; 369 } 370 371 @media (max-width: 680px) { 372 .editor-body { 373 grid-template-columns: 1fr; 374 } 375 } 376 377 .canvas-wrap { 378 display: flex; 379 flex-direction: column; 380 gap: 0.4rem; 381 } 382 383 canvas { 384 display: block; 385 cursor: crosshair; 386 border: 1.5px solid #d0c8b8; 387 border-radius: 2px; 388 background: #1a1a1a; 389 max-width: 100%; 390 touch-action: none; 391 } 392 393 canvas.hidden { 394 display: none; 395 } 396 397 .canvas-placeholder { 398 width: 300px; 399 height: 200px; 400 background: #1a1a1a; 401 border: 1.5px solid #d0c8b8; 402 border-radius: 2px; 403 display: flex; 404 align-items: center; 405 justify-content: center; 406 font-family: 'Caveat', cursive; 407 color: #666; 408 } 409 410 .canvas-hint { 411 font-family: 'Caveat', cursive; 412 font-size: 0.9rem; 413 color: #999; 414 min-height: 1.2em; 415 } 416 417 .controls { 418 display: flex; 419 flex-direction: column; 420 gap: 0.8rem; 421 min-width: 200px; 422 } 423 424 .field { 425 display: flex; 426 flex-direction: column; 427 gap: 0.3rem; 428 font-family: 'Caveat', cursive; 429 font-size: 1rem; 430 color: #555; 431 } 432 433 .checkbox-field { 434 flex-direction: row; 435 align-items: center; 436 gap: 0.5rem; 437 } 438 439 .field input[type='text'], 440 .field input[type='range'] { 441 font-family: 'Caveat', cursive; 442 font-size: 1rem; 443 padding: 0.3rem 0.5rem; 444 border: 1.5px solid #c8c0b0; 445 border-radius: 2px; 446 background: white; 447 outline: none; 448 } 449 450 .field input[type='text']:focus { 451 border-color: #a88ad8; 452 } 453 454 .color-row { 455 display: flex; 456 align-items: center; 457 gap: 0.5rem; 458 } 459 460 .hex { 461 font-family: monospace; 462 font-size: 0.85rem; 463 color: #888; 464 } 465 466 .path-actions { 467 display: flex; 468 gap: 0.4rem; 469 flex-wrap: wrap; 470 } 471 472 .btn-outline { 473 font-family: 'Caveat', cursive; 474 font-size: 0.95rem; 475 padding: 0.3rem 0.7rem; 476 background: transparent; 477 border: 1.5px solid #c4a8e8; 478 border-radius: 2px; 479 cursor: pointer; 480 color: #6a4a8a; 481 } 482 483 .btn-outline:hover { 484 background: #f0e8ff; 485 } 486 487 .actions { 488 display: flex; 489 gap: 0.6rem; 490 margin-top: 0.5rem; 491 } 492 493 .btn-cancel { 494 font-family: 'Caveat', cursive; 495 font-size: 1rem; 496 padding: 0.4rem 0.9rem; 497 background: transparent; 498 border: 1.5px solid #ccc; 499 border-radius: 2px; 500 cursor: pointer; 501 color: #888; 502 } 503 504 .btn-submit { 505 font-family: 'Caveat', cursive; 506 font-size: 1rem; 507 padding: 0.4rem 1rem; 508 background: #d0e8c0; 509 border: 2px solid #a8c890; 510 border-radius: 2px; 511 cursor: pointer; 512 color: #3a5a2a; 513 flex: 1; 514 } 515 516 .btn-submit:hover:not(:disabled) { 517 background: #bcd8ac; 518 } 519 520 .btn-submit:disabled { 521 opacity: 0.5; 522 cursor: not-allowed; 523 } 524</style>