atprotocol stickers
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
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>