fix(uploader,setup): beta-test polish — crop overlay, sticker delete, emoji keyboard, copy
- crop: invert overlay shading; the destination-out trick on a semi-transparent fill was leaving the *inside* of the crop more transparent than the outside, so the keep-area read as darker than the discard-area. Replace with 4 explicit dim-strips. - stickers: floating trash handle now glues to the selected sticker's top-right corner instead of an off-canvas X that testers missed. - stickers: replace the curated grid with an emoji-keyboard picker — recently-used row, custom-sprite row (santa hat as inline SVG), then an input that pops the OS emoji keyboard. Recents persist in localStorage; legacy stickers fall back to the old STICKERS table. - pwa-install modal: drop "browser chrome" — beta tester read it as the literal Chrome browser. - /setup landing page: tighten "Set up your frame" copy.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path d="M 8 52 L 56 52 Q 50 30 42 12 Q 32 22 24 32 Q 14 42 8 52 Z" fill="#cf2030"/>
|
||||
<path d="M 24 32 Q 14 42 8 52 Q 18 50 28 44 Q 26 38 24 32 Z" fill="#a8141d" opacity="0.55"/>
|
||||
<rect x="6" y="48" width="52" height="10" rx="5" fill="#ffffff"/>
|
||||
<ellipse cx="14" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||
<ellipse cx="28" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||
<ellipse cx="42" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||
<ellipse cx="52" cy="53" rx="2" ry="1.4" fill="#ececec"/>
|
||||
<circle cx="42" cy="12" r="7.5" fill="#ffffff"/>
|
||||
<circle cx="40" cy="10" r="2" fill="#f4f4f4" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 678 B |
@@ -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'
|
||||
|
||||
@@ -10,29 +10,46 @@
|
||||
<v-image :config="imageConfig" />
|
||||
</v-layer>
|
||||
<v-layer ref="stickerLayerRef">
|
||||
<v-text
|
||||
v-for="s in stickers"
|
||||
:key="s.id"
|
||||
:config="stickerConfig(s)"
|
||||
<template v-for="s in stickers" :key="s.id">
|
||||
<v-image
|
||||
v-if="s.imageAsset"
|
||||
:config="imageStickerConfig(s)"
|
||||
@click="selectSticker(s.id, $event)"
|
||||
@tap="selectSticker(s.id, $event)"
|
||||
@dragend="onDragEnd(s.id, $event)"
|
||||
@transformend="onTransformEnd(s.id, $event)"
|
||||
/>
|
||||
<v-text
|
||||
v-else
|
||||
:config="emojiStickerConfig(s)"
|
||||
@click="selectSticker(s.id, $event)"
|
||||
@tap="selectSticker(s.id, $event)"
|
||||
@dragend="onDragEnd(s.id, $event)"
|
||||
@transformend="onTransformEnd(s.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
</v-stage>
|
||||
|
||||
<!-- Delete selected sticker button -->
|
||||
<!-- Floating delete handle — follows the selected sticker so the user
|
||||
doesn't have to hunt for an off-canvas X. Trash icon (not X) reads
|
||||
as "remove" not "close." -->
|
||||
<button
|
||||
v-if="selectedId"
|
||||
class="sticker-canvas__delete"
|
||||
v-if="handlePos"
|
||||
class="sticker-canvas__delete-handle"
|
||||
type="button"
|
||||
aria-label="Remove sticker"
|
||||
@click="removeSelected"
|
||||
:style="{ left: handlePos.x + 'px', top: handlePos.y + 'px' }"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeSelected"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6"/>
|
||||
<path d="M14 11v6"/>
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -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<HTMLImageElement | null>(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<Record<string, HTMLImageElement>>({})
|
||||
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,10 +307,15 @@ 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,
|
||||
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,
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,115 +1,254 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<div class="sticker-tray">
|
||||
<div class="sticker-tray__cats" role="tablist">
|
||||
<!-- Recently used row — surfaces the user's last picks first so
|
||||
common stickers (a heart, a snowflake) are one tap away. -->
|
||||
<section v-if="recents.length" class="sticker-tray__section">
|
||||
<h3 class="sticker-tray__heading">Recent</h3>
|
||||
<div class="sticker-tray__row">
|
||||
<button
|
||||
v-for="cat in STICKER_CATEGORIES"
|
||||
:key="cat.id"
|
||||
v-for="r in recents"
|
||||
:key="r.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
|
||||
@click="activeCategory = cat.id"
|
||||
>{{ cat.label }}</button>
|
||||
</div>
|
||||
<div class="sticker-tray__grid" role="tabpanel">
|
||||
<button
|
||||
v-for="s in visibleStickers"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="sticker-tray__item"
|
||||
:aria-label="s.label"
|
||||
@click="$emit('pick', s.id)"
|
||||
class="sticker-tray__chip"
|
||||
:aria-label="r.label"
|
||||
@click="pickRecent(r)"
|
||||
>
|
||||
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
|
||||
<span class="sticker-tray__label">{{ s.label }}</span>
|
||||
<span v-if="r.kind === 'emoji'" class="sticker-tray__emoji" aria-hidden="true">{{ r.emoji }}</span>
|
||||
<img v-else :src="r.url" alt="" class="sticker-tray__img" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Custom (non-emoji) sprites the user asked for. Renders alongside
|
||||
recents so they're easy to grab without navigating away. -->
|
||||
<section class="sticker-tray__section">
|
||||
<h3 class="sticker-tray__heading">Stickers</h3>
|
||||
<div class="sticker-tray__row">
|
||||
<button
|
||||
v-for="a in CUSTOM_STICKER_ASSETS"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="sticker-tray__chip"
|
||||
:aria-label="a.label"
|
||||
@click="pickCustom(a)"
|
||||
>
|
||||
<img :src="a.url" :alt="a.label" class="sticker-tray__img" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Emoji input — focusing the field opens the OS emoji keyboard
|
||||
on iOS/Android. Desktop users can paste or type any emoji.
|
||||
We capture the *last* grapheme so users can pick several in a
|
||||
row without the field accumulating. -->
|
||||
<section class="sticker-tray__section">
|
||||
<h3 class="sticker-tray__heading">Any emoji</h3>
|
||||
<p class="sticker-tray__hint">Tap the box, then pick from your keyboard's emoji button.</p>
|
||||
<input
|
||||
ref="emojiInputRef"
|
||||
type="text"
|
||||
class="sticker-tray__emoji-input"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="😀 🎉 🐶 …"
|
||||
aria-label="Emoji input — use your keyboard's emoji button"
|
||||
@input="onEmojiInput"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
|
||||
import type { StickerCategory } from '@/assets/stickers/index'
|
||||
import { CUSTOM_STICKER_ASSETS, type CustomStickerAsset } from '@/assets/stickers/customAssets'
|
||||
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'pick', stickerId: string): void
|
||||
(e: 'pick', payload: { emoji?: string; imageAsset?: string }): void
|
||||
}>()
|
||||
|
||||
const activeCategory = ref<StickerCategory>('seasonal')
|
||||
interface RecentItem {
|
||||
key: string
|
||||
kind: 'emoji' | 'image'
|
||||
label: string
|
||||
emoji?: string
|
||||
imageAsset?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const visibleStickers = computed(() =>
|
||||
STICKERS.filter(s => s.category === activeCategory.value)
|
||||
)
|
||||
const RECENTS_KEY = 'pf.stickerTray.recents'
|
||||
const MAX_RECENTS = 12
|
||||
|
||||
const recents = ref<RecentItem[]>([])
|
||||
const emojiInputRef = ref<HTMLInputElement>()
|
||||
|
||||
onMounted(() => {
|
||||
recents.value = loadRecents()
|
||||
})
|
||||
|
||||
function loadRecents(): RecentItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENTS_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as RecentItem[]
|
||||
// Re-hydrate image URLs from the asset registry — assets may move
|
||||
// across builds, but the id stays stable.
|
||||
return parsed.map(r => {
|
||||
if (r.kind === 'image' && r.imageAsset) {
|
||||
const a = CUSTOM_STICKER_ASSETS.find(x => x.id === r.imageAsset)
|
||||
return { ...r, url: a?.url, label: a?.label ?? r.label }
|
||||
}
|
||||
return r
|
||||
}).filter(r => r.kind === 'emoji' ? !!r.emoji : !!r.url)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function persistRecents() {
|
||||
try {
|
||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.value))
|
||||
} catch { /* quota / private mode — non-fatal */ }
|
||||
}
|
||||
|
||||
function rememberRecent(item: RecentItem) {
|
||||
recents.value = [item, ...recents.value.filter(r => r.key !== item.key)].slice(0, MAX_RECENTS)
|
||||
persistRecents()
|
||||
}
|
||||
|
||||
function firstGrapheme(input: string): string | null {
|
||||
if (!input) return null
|
||||
// Intl.Segmenter understands ZWJ-joined emoji ("👨👩👧" is one grapheme,
|
||||
// not five) so multi-codepoint emoji come through whole.
|
||||
if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
|
||||
const seg = new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
|
||||
const list = [...seg.segment(input)] as Array<{ segment: string }>
|
||||
return list[list.length - 1]?.segment ?? null
|
||||
}
|
||||
// Fallback for older browsers without Intl.Segmenter.
|
||||
return [...input].pop() ?? null
|
||||
}
|
||||
|
||||
function onEmojiInput(e: Event) {
|
||||
const el = e.target as HTMLInputElement
|
||||
const grapheme = firstGrapheme(el.value)
|
||||
el.value = ''
|
||||
if (!grapheme) return
|
||||
const item: RecentItem = {
|
||||
key: `emoji:${grapheme}`,
|
||||
kind: 'emoji',
|
||||
label: grapheme,
|
||||
emoji: grapheme,
|
||||
}
|
||||
rememberRecent(item)
|
||||
emit('pick', { emoji: grapheme })
|
||||
}
|
||||
|
||||
function pickCustom(a: CustomStickerAsset) {
|
||||
rememberRecent({
|
||||
key: `image:${a.id}`,
|
||||
kind: 'image',
|
||||
label: a.label,
|
||||
imageAsset: a.id,
|
||||
url: a.url,
|
||||
})
|
||||
emit('pick', { imageAsset: a.id })
|
||||
}
|
||||
|
||||
function pickRecent(r: RecentItem) {
|
||||
rememberRecent(r)
|
||||
if (r.kind === 'emoji' && r.emoji) {
|
||||
emit('pick', { emoji: r.emoji })
|
||||
} else if (r.kind === 'image' && r.imageAsset) {
|
||||
emit('pick', { imageAsset: r.imageAsset })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sticker-tray {
|
||||
&__cats {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
&__section {
|
||||
margin-bottom: var(--space-4);
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
&__cat {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
&__heading {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
&__hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__item {
|
||||
&__chip {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
justify-content: center;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast);
|
||||
transition: background var(--duration-fast), border-color var(--duration-fast);
|
||||
|
||||
&:active { background: var(--color-surface-2); }
|
||||
&:active {
|
||||
background: var(--color-surface-2);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__emoji {
|
||||
font-size: 36px;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
&__img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__emoji-input {
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
// 16px+ avoids iOS Safari's auto-zoom-on-focus.
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
</li>
|
||||
</ol>
|
||||
<p class="install-modal__footer">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Set up your frame</h1>
|
||||
<p class="subtitle">Create an account or sign in to link this frame.</p>
|
||||
<h1>Link this frame</h1>
|
||||
<p class="subtitle">Create an account, or sign in if you already have one. The frame will link to whichever account you use here.</p>
|
||||
|
||||
{% if already_claimed %}
|
||||
<p class="claim-banner" role="status">
|
||||
|
||||
Reference in New Issue
Block a user