diff --git a/frontend/src/assets/stickers/customAssets.ts b/frontend/src/assets/stickers/customAssets.ts
new file mode 100644
index 0000000..9b4dec0
--- /dev/null
+++ b/frontend/src/assets/stickers/customAssets.ts
@@ -0,0 +1,15 @@
+import santaHatUrl from './santa-hat.svg?url'
+
+export interface CustomStickerAsset {
+ id: string
+ label: string
+ url: string
+}
+
+export const CUSTOM_STICKER_ASSETS: CustomStickerAsset[] = [
+ { id: 'santa-hat', label: 'Santa hat', url: santaHatUrl },
+]
+
+export function customAssetUrl(id: string): string | undefined {
+ return CUSTOM_STICKER_ASSETS.find(a => a.id === id)?.url
+}
diff --git a/frontend/src/assets/stickers/santa-hat.svg b/frontend/src/assets/stickers/santa-hat.svg
new file mode 100644
index 0000000..182e968
--- /dev/null
+++ b/frontend/src/assets/stickers/santa-hat.svg
@@ -0,0 +1,11 @@
+
diff --git a/frontend/src/components/CropEditor.vue b/frontend/src/components/CropEditor.vue
index 3105213..ff90802 100644
--- a/frontend/src/components/CropEditor.vue
+++ b/frontend/src/components/CropEditor.vue
@@ -216,13 +216,18 @@ function draw() {
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
- // Dark overlay outside crop
- ctx.save()
- ctx.fillStyle = 'rgba(0,0,0,0.55)'
- ctx.fillRect(0, 0, width, height)
- ctx.globalCompositeOperation = 'destination-out'
- ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
- ctx.restore()
+ // Dim *outside* the crop with 4 strips. The previous destination-out
+ // trick using a semi-transparent fill made the crop area partially
+ // transparent (revealing the black container bg) instead of leaving
+ // it untouched, so the inside ended up *darker* than the outside —
+ // the inverse of what a crop overlay is meant to do.
+ const cx2 = cropRect.x + cropRect.w
+ const cy2 = cropRect.y + cropRect.h
+ ctx.fillStyle = 'rgba(0,0,0,0.6)'
+ ctx.fillRect(0, 0, width, cropRect.y) // top
+ ctx.fillRect(0, cy2, width, height - cy2) // bottom
+ ctx.fillRect(0, cropRect.y, cropRect.x, cropRect.h) // left
+ ctx.fillRect(cx2, cropRect.y, width - cx2, cropRect.h) // right
// Crop border + corner marks
ctx.strokeStyle = '#fff'
diff --git a/frontend/src/components/StickerCanvas.vue b/frontend/src/components/StickerCanvas.vue
index a7ac5ef..a47dc74 100644
--- a/frontend/src/components/StickerCanvas.vue
+++ b/frontend/src/components/StickerCanvas.vue
@@ -10,29 +10,46 @@
-
+
+
+
+
-
+
@@ -109,8 +126,18 @@ onMounted(() => {
onBeforeUnmount(() => {
ro.disconnect()
detachPinchListeners()
+ detachHandleListeners?.()
})
+// Pinch-resize and the parent's stickers prop both bypass Konva's
+// drag/transform events, so refresh the handle when the props change.
+watch(() => props.stickers, () => {
+ if (!selectedId.value) return
+ const layer = stickerLayerRef.value?.getNode()
+ const node = layer?.findOne(`#${selectedId.value}`)
+ if (node) refreshHandle(node)
+}, { deep: true })
+
// ── Image ─────────────────────────────────────────────────────────────────────
const bgImage = ref(null)
@@ -121,6 +148,21 @@ function loadImage() {
}
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
+// ── Custom-sprite asset preloader ─────────────────────────────────────────────
+// Image-based stickers need an HTMLImageElement loaded before Konva can
+// paint them; preload all known custom assets up front so the first
+// pick renders without a flicker.
+const imageAssetCache = ref>({})
+onMounted(() => {
+ for (const a of CUSTOM_STICKER_ASSETS) {
+ const i = new Image()
+ i.onload = () => {
+ imageAssetCache.value = { ...imageAssetCache.value, [a.id]: i }
+ }
+ i.src = a.url
+ }
+})
+
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
const imageConfig = computed(() => ({
image: bgImage.value,
@@ -142,11 +184,12 @@ const transformerConfig = {
// ── Stickers ──────────────────────────────────────────────────────────────────
const EMOJI_FONT_SIZE = 52
+const IMAGE_STICKER_BASE_SIZE = 96
-function stickerConfig(s: StickerLayer) {
+function emojiStickerConfig(s: StickerLayer) {
return {
id: s.id,
- text: stickerEmoji(s.type),
+ text: emojiFor(s),
fontSize: EMOJI_FONT_SIZE,
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
x: s.x,
@@ -160,9 +203,63 @@ function stickerConfig(s: StickerLayer) {
}
}
+function imageStickerConfig(s: StickerLayer) {
+ return {
+ id: s.id,
+ image: s.imageAsset ? imageAssetCache.value[s.imageAsset] : null,
+ x: s.x,
+ y: s.y,
+ width: IMAGE_STICKER_BASE_SIZE,
+ height: IMAGE_STICKER_BASE_SIZE,
+ scaleX: s.scale,
+ scaleY: s.scale,
+ rotation: s.rotation,
+ draggable: true,
+ offsetX: IMAGE_STICKER_BASE_SIZE / 2,
+ offsetY: IMAGE_STICKER_BASE_SIZE / 2,
+ }
+}
+
import { STICKERS } from '@/assets/stickers/index'
-function stickerEmoji(type: string): string {
- return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
+import { CUSTOM_STICKER_ASSETS, customAssetUrl } from '@/assets/stickers/customAssets'
+
+function emojiFor(s: StickerLayer): string {
+ // New stickers carry the glyph directly; legacy stickers fall back to
+ // the curated table by id.
+ if (s.emoji) return s.emoji
+ return STICKERS.find(x => x.id === s.type)?.emoji ?? '⭐'
+}
+
+// Screen-space position of the delete handle. Tracks the selected
+// sticker's transformed bbox so the handle stays glued to its top-right
+// corner through drag/resize/rotate.
+const handlePos = ref<{ x: number; y: number } | null>(null)
+let detachHandleListeners: (() => void) | null = null
+
+function refreshHandle(node: any) {
+ const box = node.getClientRect()
+ // Sit just outside the top-right of the sticker; nudge inward a touch
+ // so it never disappears beyond the stage edge for a corner sticker.
+ const margin = 4
+ const half = 14
+ let x = box.x + box.width + margin - half
+ let y = box.y - margin - half
+ // Clamp inside stage so the button is always tappable.
+ x = Math.max(half, Math.min(stageW.value - half, x))
+ y = Math.max(half, Math.min(stageH.value - half, y))
+ handlePos.value = { x, y }
+}
+
+function attachHandleTracking(id: string) {
+ detachHandleListeners?.()
+ detachHandleListeners = null
+ const layer = stickerLayerRef.value?.getNode()
+ const node = layer?.findOne(`#${id}`)
+ if (!node) { handlePos.value = null; return }
+ refreshHandle(node)
+ const update = () => refreshHandle(node)
+ node.on('dragmove.handle transform.handle transformend.handle dragend.handle', update)
+ detachHandleListeners = () => node.off('.handle')
}
function selectSticker(id: string, e: any) {
@@ -173,6 +270,7 @@ function selectSticker(id: string, e: any) {
const node = layer?.findOne(`#${id}`)
const tr = transformerRef.value?.getNode()
if (node && tr) tr.nodes([node])
+ attachHandleTracking(id)
})
}
@@ -180,6 +278,9 @@ function onStageClick(e: any) {
if (e.target === e.target.getStage()) {
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
+ detachHandleListeners?.()
+ detachHandleListeners = null
+ handlePos.value = null
}
}
@@ -188,6 +289,9 @@ function removeSelected() {
emit('remove-sticker', selectedId.value)
selectedId.value = null
transformerRef.value?.getNode()?.nodes([])
+ detachHandleListeners?.()
+ detachHandleListeners = null
+ handlePos.value = null
}
function onDragEnd(id: string, e: any) {
@@ -203,14 +307,19 @@ function onTransformEnd(id: string, e: any) {
})
}
-function addStickerFromTray(stickerId: string) {
+function addStickerFromTray(payload: { emoji?: string; imageAsset?: string }) {
+ const seed = payload.imageAsset ?? payload.emoji ?? 'sticker'
const s: StickerLayer = {
- id: `${stickerId}-${Date.now()}`,
- type: stickerId,
- x: stageW.value / 2,
- y: stageH.value / 2,
- scale: 1,
- rotation: 0,
+ id: `${seed}-${Date.now()}`,
+ // `type` doubles as a coarse kind label so legacy serialization
+ // keeps a non-empty value (the field is required server-side).
+ type: payload.imageAsset ? 'image' : 'emoji',
+ emoji: payload.emoji,
+ imageAsset: payload.imageAsset,
+ x: stageW.value / 2,
+ y: stageH.value / 2,
+ scale: 1,
+ rotation: 0,
}
emit('add-sticker', s)
trayOpen.value = false
@@ -299,21 +408,24 @@ async function done() {
flex-shrink: 0;
}
- &__delete {
+ &__delete-handle {
position: absolute;
- top: var(--space-3);
- right: var(--space-3);
- width: 36px;
- height: 36px;
+ width: 28px;
+ height: 28px;
+ margin: 0;
+ padding: 0;
border-radius: 50%;
- background: rgba(200, 30, 30, 0.85);
- border: none;
+ background: rgba(200, 30, 30, 0.95);
+ border: 2px solid #fff;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 10;
+ transform: translate(-50%, -50%);
+ touch-action: none;
}
&__bar {
diff --git a/frontend/src/components/StickerTray.vue b/frontend/src/components/StickerTray.vue
index 00e5f4c..36bc950 100644
--- a/frontend/src/components/StickerTray.vue
+++ b/frontend/src/components/StickerTray.vue
@@ -1,115 +1,254 @@
-
-
-
-
-
-
+
+
+
Recent
+
+
+
+
+
+
+
+
Stickers
+
+
+
+
+
+
+
+
Any emoji
+
Tap the box, then pick from your keyboard's emoji button.
+
+
diff --git a/frontend/src/test/components/StickerTray.test.ts b/frontend/src/test/components/StickerTray.test.ts
index 0b66879..d41d546 100644
--- a/frontend/src/test/components/StickerTray.test.ts
+++ b/frontend/src/test/components/StickerTray.test.ts
@@ -1,9 +1,8 @@
-import { describe, it, expect, vi } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
import StickerTray from '@/components/StickerTray.vue'
-import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
+import { CUSTOM_STICKER_ASSETS } from '@/assets/stickers/customAssets'
-// Stub the bottom sheet so the modal contents are always rendered inline
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
@@ -13,36 +12,63 @@ vi.mock('@/components/BaseBottomSheet.vue', () => ({
},
}))
-describe('StickerTray', () => {
- it('renders one tab per sticker category', () => {
+beforeEach(() => {
+ localStorage.clear()
+})
+
+describe('StickerTray (emoji-keyboard picker)', () => {
+ it('renders the custom-sticker row', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
- expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length)
+ await flushPromises()
+ const chips = wrapper.findAll('.sticker-tray__chip')
+ expect(chips.length).toBeGreaterThanOrEqual(CUSTOM_STICKER_ASSETS.length)
})
- it('starts on the seasonal category', () => {
+ it('emits pick with imageAsset when a custom sticker is tapped', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
- const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal')
- expect(seasonal?.classes()).toContain('sticker-tray__cat--active')
- const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length
- expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount)
- })
-
- it('switches the visible grid when a different category tab is clicked', async () => {
- const wrapper = mount(StickerTray, { props: { modelValue: true } })
- const fun = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Fun')!
- await fun.trigger('click')
- const funCount = STICKERS.filter(s => s.category === 'fun').length
- expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(funCount)
- expect(fun.classes()).toContain('sticker-tray__cat--active')
- })
-
- it('emits "pick" with the sticker id when an item is clicked', async () => {
- const wrapper = mount(StickerTray, { props: { modelValue: true } })
- const firstItem = wrapper.find('.sticker-tray__item')
- await firstItem.trigger('click')
+ await flushPromises()
+ const santa = wrapper.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
+ expect(santa).toBeTruthy()
+ await santa.trigger('click')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
- expect(typeof events![0][0]).toBe('string')
+ expect(events![0][0]).toEqual({ imageAsset: 'santa-hat' })
+ })
+
+ it('emits pick with the typed emoji when the input fires', async () => {
+ const wrapper = mount(StickerTray, { props: { modelValue: true } })
+ await flushPromises()
+ const input = wrapper.find('.sticker-tray__emoji-input')
+ expect(input.exists()).toBe(true)
+ ;(input.element as HTMLInputElement).value = '🎉'
+ await input.trigger('input')
+ const events = wrapper.emitted('pick')
+ expect(events).toBeTruthy()
+ expect(events![0][0]).toEqual({ emoji: '🎉' })
+ })
+
+ it('clears the input after each emoji pick so the next pick is independent', async () => {
+ const wrapper = mount(StickerTray, { props: { modelValue: true } })
+ await flushPromises()
+ const input = wrapper.find('.sticker-tray__emoji-input').element as HTMLInputElement
+ input.value = '😀'
+ await wrapper.find('.sticker-tray__emoji-input').trigger('input')
+ expect(input.value).toBe('')
+ })
+
+ it('persists picks to a Recent row across mounts', async () => {
+ const first = mount(StickerTray, { props: { modelValue: true } })
+ await flushPromises()
+ const input = first.find('.sticker-tray__emoji-input')
+ ;(input.element as HTMLInputElement).value = '🌈'
+ await input.trigger('input')
+
+ const second = mount(StickerTray, { props: { modelValue: true } })
+ await flushPromises()
+ const headings = second.findAll('.sticker-tray__heading').map(h => h.text())
+ expect(headings).toContain('Recent')
+ const recentChip = second.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === '🌈')
+ expect(recentChip).toBeTruthy()
})
it('forwards update:modelValue from the wrapped sheet', async () => {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 5299923..cbc6139 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -40,6 +40,12 @@ export interface StickerLayer {
y: number
scale: number
rotation: number
+ /** New stickers carry the emoji glyph directly; legacy stickers leave this
+ * blank and the renderer falls back to the STICKERS lookup by `type`. */
+ emoji?: string
+ /** Set for image-based stickers (e.g. 'santa-hat') that aren't representable
+ * as a single Unicode emoji. Mutually exclusive with `emoji`. */
+ imageAsset?: string
}
export interface Image {
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 44878f0..4f4231d 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -94,8 +94,8 @@
- The app will appear on your home screen and open without
- browser chrome the next time you launch it.
+ The app will appear on your home screen. Open it from there
+ and it runs like a regular app — no address bar, no tabs.