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 @@ 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 @@ diff --git a/templates/setup/index.html.twig b/templates/setup/index.html.twig index 83e1711..5435f4e 100644 --- a/templates/setup/index.html.twig +++ b/templates/setup/index.html.twig @@ -38,8 +38,8 @@
-

Set up your frame

-

Create an account or sign in to link this frame.

+

Link this frame

+

Create an account, or sign in if you already have one. The frame will link to whichever account you use here.

{% if already_claimed %}