atprotocol stickers
Something went wrong. Try again.
13 kB · 524 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525<script lang="ts"> import { onMount } from 'svelte';
interface Props { file: File; onDone: (result: { imageBlob: Blob; maskPath: string; imageWidth: number; imageHeight: number; borderColor: string; borderThickness: number; shortname: string; }) => void; onCancel: () => void; }
let { file, onDone, onCancel }: Props = $props();
// Form state let shortname = $state(''); let borderColor = $state('#ff99cc'); let borderThickness = $state(8); let borderEnabled = $state(false); let processing = $state(false);
// Canvas + image let canvas: HTMLCanvasElement; let img: HTMLImageElement; let imgLoaded = $state(false); let displayW = 0; let displayH = 0; let scaleX = 1; let scaleY = 1;
// Drawing state type Point = { x: number; y: number }; let points = $state<Point[]>([]); let isDrawing = $state(false); let pathClosed = $state(false); const CLOSE_RADIUS = 14; // px — snap-close distance
onMount(() => { shortname = file.name.replace(/\.[^.]+$/, '').slice(0, 50); img = new Image(); img.onload = () => { imgLoaded = true; // Fit image into max 520×420 display area const maxW = 520; const maxH = 420; const ratio = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1); displayW = Math.round(img.naturalWidth * ratio); displayH = Math.round(img.naturalHeight * ratio); scaleX = img.naturalWidth / displayW; scaleY = img.naturalHeight / displayH; canvas.width = displayW; canvas.height = displayH; redraw(); }; img.src = URL.createObjectURL(file); return () => URL.revokeObjectURL(img.src); });
$effect(() => { // Redraw preview when border settings change const _deps = [borderEnabled, borderColor, borderThickness, pathClosed, points.length]; if (imgLoaded) redraw(); });
function redraw() { if (!canvas || !img) return; const ctx = canvas.getContext('2d')!; ctx.clearRect(0, 0, displayW, displayH);
// Draw image dimmed ctx.globalAlpha = pathClosed ? 0.35 : 0.75; ctx.drawImage(img, 0, 0, displayW, displayH); ctx.globalAlpha = 1;
if (points.length < 2) return;
// Draw the current/closed path ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const curr = points[i]; // Smooth with midpoints ctx.quadraticCurveTo(prev.x, prev.y, (prev.x + curr.x) / 2, (prev.y + curr.y) / 2); } if (pathClosed) ctx.closePath();
// If closed: border outside, then image clipped inside if (pathClosed) { // Border first (before clip) so it sits outside the image if (borderEnabled && borderThickness > 0) { ctx.strokeStyle = borderColor; ctx.lineWidth = borderThickness * 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); } // Clip and draw image on top, covering the inner half of the stroke ctx.save(); ctx.clip(); ctx.globalAlpha = 1; ctx.drawImage(img, 0, 0, displayW, displayH); ctx.restore(); }
// Draw path outline ctx.strokeStyle = pathClosed ? 'rgba(255,255,255,0.6)' : '#ffffff'; ctx.lineWidth = pathClosed ? 1 : 2; ctx.setLineDash(pathClosed ? [] : [6, 3]); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); ctx.setLineDash([]);
// Close-snap indicator when near start if (!pathClosed && points.length > 3) { ctx.beginPath(); ctx.arc(points[0].x, points[0].y, CLOSE_RADIUS, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255,255,100,0.7)'; ctx.lineWidth = 1.5; ctx.stroke(); } }
function getPos(e: MouseEvent | TouchEvent): Point { const rect = canvas.getBoundingClientRect(); const src = e instanceof TouchEvent ? e.touches[0] : e; return { x: (src.clientX - rect.left) * (displayW / rect.width), y: (src.clientY - rect.top) * (displayH / rect.height) }; }
function isNearStart(p: Point): boolean { if (points.length < 4) return false; const dx = p.x - points[0].x; const dy = p.y - points[0].y; return Math.sqrt(dx * dx + dy * dy) < CLOSE_RADIUS; }
function startDraw(e: MouseEvent | TouchEvent) { e.preventDefault(); if (pathClosed) return; isDrawing = true; const p = getPos(e); points = [p]; }
function continueDraw(e: MouseEvent | TouchEvent) { e.preventDefault(); if (!isDrawing || pathClosed) return; const p = getPos(e); // Sub-sample: only add point if moved enough const last = points[points.length - 1]; const dx = p.x - last.x; const dy = p.y - last.y; if (dx * dx + dy * dy < 9) return; points = [...points, p]; redraw(); }
function endDraw(e: MouseEvent | TouchEvent) { e.preventDefault(); if (!isDrawing) return; isDrawing = false; const p = getPos(e); if (isNearStart(p) || points.length > 8) { pathClosed = true; } redraw(); }
function closePath() { if (points.length > 2) { pathClosed = true; redraw(); } }
function resetPath() { points = []; pathClosed = false; redraw(); }
async function buildImageAndPath(): Promise<{ imageBlob: Blob; maskPath: string; imageWidth: number; imageHeight: number; }> { // Bounding box of drawn path in display pixels const xs = points.map((p) => p.x); const ys = points.map((p) => p.y); const bboxX = Math.max(0, Math.floor(Math.min(...xs))); const bboxY = Math.max(0, Math.floor(Math.min(...ys))); const bboxMaxX = Math.min(displayW, Math.ceil(Math.max(...xs))); const bboxMaxY = Math.min(displayH, Math.ceil(Math.max(...ys)));
// Scale to natural image pixel crop region const cropX = Math.round(bboxX * scaleX); const cropY = Math.round(bboxY * scaleY); const cropW = Math.round((bboxMaxX - bboxX) * scaleX); const cropH = Math.round((bboxMaxY - bboxY) * scaleY);
// Build SVG path in natural image pixels, relative to crop origin const sx = (x: number) => Math.round((x - bboxX) * scaleX); const sy = (y: number) => Math.round((y - bboxY) * scaleY); let d = `M ${sx(points[0].x)} ${sy(points[0].y)}`; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const curr = points[i]; d += ` Q ${sx(prev.x)} ${sy(prev.y)} ${sx((prev.x + curr.x) / 2)} ${sy((prev.y + curr.y) / 2)}`; } d += ' Z';
// Crop to bbox, then mask out pixels outside the drawn path using destination-in compositing. // Exported as PNG to preserve transparency. const out = document.createElement('canvas'); out.width = cropW; out.height = cropH; const outCtx = out.getContext('2d')!; outCtx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
// Keep only pixels inside the mask path; clear everything outside outCtx.globalCompositeOperation = 'destination-in'; outCtx.fill(new Path2D(d)); outCtx.globalCompositeOperation = 'source-over';
const imageBlob = await new Promise<Blob>((resolve, reject) => out.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png') );
return { imageBlob, maskPath: d, imageWidth: cropW, imageHeight: cropH }; }
async function handleSubmit() { if (!shortname.trim() || !pathClosed) return; processing = true; try { const { imageBlob, maskPath, imageWidth, imageHeight } = await buildImageAndPath(); onDone({ imageBlob, maskPath, imageWidth, imageHeight, borderColor: borderEnabled ? borderColor : '', // Store border thickness in natural image pixels so SVG can use directly borderThickness: borderEnabled ? Math.round(borderThickness * scaleX) : 0, shortname: shortname.trim() }); } finally { processing = false; } }</script>
<div class="editor"> <h2>create sticker</h2>
<div class="editor-body"> <!-- Drawing canvas --> <div class="canvas-wrap"> {#if !imgLoaded} <div class="canvas-placeholder">loading…</div> {/if} <canvas bind:this={canvas} class:hidden={!imgLoaded} onmousedown={startDraw} onmousemove={continueDraw} onmouseup={endDraw} onmouseleave={endDraw} ontouchstart={startDraw} ontouchmove={continueDraw} ontouchend={endDraw} ></canvas> <div class="canvas-hint"> {#if !imgLoaded} {:else if pathClosed} ✓ shape drawn {:else if points.length > 3} draw back to the dot to close, or click "close shape" {:else} click and drag to draw your sticker shape {/if} </div> </div>
<!-- Controls --> <div class="controls"> <label class="field"> <span>shortname</span> <input type="text" bind:value={shortname} maxlength="50" placeholder="my cool sticker" /> </label>
<label class="field checkbox-field"> <input type="checkbox" bind:checked={borderEnabled} /> <span>add border</span> </label>
{#if borderEnabled} <label class="field"> <span>border color</span> <div class="color-row"> <input type="color" bind:value={borderColor} /> <span class="hex">{borderColor}</span> </div> </label> <label class="field"> <span>thickness: {borderThickness}px</span> <input type="range" min="1" max="30" bind:value={borderThickness} /> </label> {/if}
<div class="path-actions"> {#if !pathClosed && points.length > 2} <button class="btn-outline" onclick={closePath}>close shape</button> {/if} {#if points.length > 0} <button class="btn-outline" onclick={resetPath}>redraw</button> {/if} </div>
<div class="actions"> <button class="btn-cancel" onclick={onCancel}>cancel</button> <button class="btn-submit" onclick={handleSubmit} disabled={processing || !shortname.trim() || !pathClosed} > {processing ? 'processing…' : 'use this sticker →'} </button> </div> </div> </div></div>
<style> .editor { background: #fffef8; border: 2px solid #e0d4c0; border-radius: 2px; padding: 1.5rem; box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.12); max-width: 900px; margin: 0 auto; }
h2 { font-family: 'Caveat', cursive; font-size: 2rem; margin: 0 0 1.2rem; color: #5a4a6a; transform: rotate(-0.5deg); }
.editor-body { display: grid; grid-template-columns: 1fr auto; gap: 1.5rem; align-items: start; }
@media (max-width: 680px) { .editor-body { grid-template-columns: 1fr; } }
.canvas-wrap { display: flex; flex-direction: column; gap: 0.4rem; }
canvas { display: block; cursor: crosshair; border: 1.5px solid #d0c8b8; border-radius: 2px; background: #1a1a1a; max-width: 100%; touch-action: none; }
canvas.hidden { display: none; }
.canvas-placeholder { width: 300px; height: 200px; background: #1a1a1a; border: 1.5px solid #d0c8b8; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-family: 'Caveat', cursive; color: #666; }
.canvas-hint { font-family: 'Caveat', cursive; font-size: 0.9rem; color: #999; min-height: 1.2em; }
.controls { display: flex; flex-direction: column; gap: 0.8rem; min-width: 200px; }
.field { display: flex; flex-direction: column; gap: 0.3rem; font-family: 'Caveat', cursive; font-size: 1rem; color: #555; }
.checkbox-field { flex-direction: row; align-items: center; gap: 0.5rem; }
.field input[type='text'], .field input[type='range'] { font-family: 'Caveat', cursive; font-size: 1rem; padding: 0.3rem 0.5rem; border: 1.5px solid #c8c0b0; border-radius: 2px; background: white; outline: none; }
.field input[type='text']:focus { border-color: #a88ad8; }
.color-row { display: flex; align-items: center; gap: 0.5rem; }
.hex { font-family: monospace; font-size: 0.85rem; color: #888; }
.path-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.btn-outline { font-family: 'Caveat', cursive; font-size: 0.95rem; padding: 0.3rem 0.7rem; background: transparent; border: 1.5px solid #c4a8e8; border-radius: 2px; cursor: pointer; color: #6a4a8a; }
.btn-outline:hover { background: #f0e8ff; }
.actions { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.btn-cancel { font-family: 'Caveat', cursive; font-size: 1rem; padding: 0.4rem 0.9rem; background: transparent; border: 1.5px solid #ccc; border-radius: 2px; cursor: pointer; color: #888; }
.btn-submit { font-family: 'Caveat', cursive; font-size: 1rem; padding: 0.4rem 1rem; background: #d0e8c0; border: 2px solid #a8c890; border-radius: 2px; cursor: pointer; color: #3a5a2a; flex: 1; }
.btn-submit:hover:not(:disabled) { background: #bcd8ac; }
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }</style>