From a032825af1dc10d3ed2d63490cef24723159ff4b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 12 Oct 2026 23:31:42 +0000 Subject: [PATCH] fix: emoji picker places itself on screen (flips down/up/right/left), keycap emoji images (0.9.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On /settings the picker kept the composer's "open upward" CSS inside a zero-height wrapper, so it opened above the button and ran under the sticky navbar: search and category tabs hidden, the grid looking empty. The reaction picker had the same problem near the top of the message list, clipped by the scroller. EmojiPicker is now position: fixed next to an anchor (anchorRef, or the element it is rendered in). placePopover() tries the preferred side, then the opposite, then right, then left, clamps inside the viewport below the navbar, and shrinks to the taller side when nothing fits whole. Settings anchors to the emoji button and prefers below; reactions end-align on your own messages; the composer still opens above. emojiKey() now pads codepoints to four hex digits like the OpenEmoji files, so #️⃣ 0️⃣-9️⃣ *️⃣ ©️ ®️ (14 emoji) stop 404ing in the picker and in messages. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/lib/components/chat/EmojiPicker.jsx | 67 +++++++++++++++++-- src/lib/components/chat/MessageItem.jsx | 4 +- .../components/settings/ProfileSettings.jsx | 15 +++-- src/lib/emoji/openemoji.js | 7 +- src/lib/emoji/openemoji.test.js | 18 +++++ src/lib/emoji/placement.js | 50 ++++++++++++++ src/lib/emoji/placement.test.js | 53 +++++++++++++++ 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 src/lib/emoji/openemoji.test.js create mode 100644 src/lib/emoji/placement.js create mode 100644 src/lib/emoji/placement.test.js diff --git a/package.json b/package.json index c8c41c8a..ec18af0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.9.0", + "version": "0.9.1", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/src/lib/components/chat/EmojiPicker.jsx b/src/lib/components/chat/EmojiPicker.jsx index 4f2d9172..9b96566a 100644 --- a/src/lib/components/chat/EmojiPicker.jsx +++ b/src/lib/components/chat/EmojiPicker.jsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { loadEmojiIndex, searchEmoji, readRecent, pushRecent, emojiSrc, OPENICON_SPRITE } from '@/lib/emoji/openemoji.js'; +import { placePopover } from '@/lib/emoji/placement.js'; /** OpenIcon glyph per OpenEmoji group, in the set's own group order. */ const GROUP_ICONS = { @@ -25,12 +26,30 @@ export function Icon({ name, size = 18 }) { } const RECENT = 'recent'; +const EDGE = 8; + +/** The visible viewport, less a sticky navbar across the top. */ +function viewportBounds() { + let top = EDGE; + const nav = document.querySelector('.navbar'); + if (nav) { + const pos = getComputedStyle(nav).position; + const r = nav.getBoundingClientRect(); + if ((pos === 'sticky' || pos === 'fixed') && r.bottom > 0) top = Math.max(top, r.bottom + EDGE); + } + return { top, left: EDGE, right: window.innerWidth - EDGE, bottom: window.innerHeight - EDGE }; +} /** * The OpenEmoji picker. It only ever hands back a Unicode character through * onPick: what the message stores (and encrypts) is plain text. + * + * It is position: fixed next to its anchor (anchorRef, or else the element it + * is rendered in), on the `prefer` side when that fits and flipped to + * below/above/right/left when it does not, so a scroll container or the navbar + * never cuts it off. align 'end' lines it up with the anchor's right edge. */ -export default function EmojiPicker({ onPick, onClose }) { +export default function EmojiPicker({ onPick, onClose, anchorRef, prefer = 'top', align = 'start' }) { const [index, setIndex] = useState(null); const [error, setError] = useState(''); const [query, setQuery] = useState(''); @@ -38,13 +57,40 @@ export default function EmojiPicker({ onPick, onClose }) { const [tab, setTab] = useState(() => (readRecent().length ? RECENT : 0)); const rootRef = useRef(null); const searchRef = useRef(null); + const [place, setPlace] = useState(null); + + useLayoutEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + function update() { + const anchor = anchorRef?.current || root.parentElement; + if (!anchor) return; + const next = placePopover({ + anchor: anchor.getBoundingClientRect(), + size: { width: root.offsetWidth, height: 380 }, + bounds: viewportBounds(), + prefer, + align, + }); + setPlace((prev) => + prev && prev.top === next.top && prev.left === next.left && prev.maxHeight === next.maxHeight ? prev : next, + ); + } + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [anchorRef, prefer, align]); useEffect(() => { let live = true; loadEmojiIndex() .then((i) => live && setIndex(i)) .catch((err) => live && setError(err.message)); - searchRef.current?.focus(); + searchRef.current?.focus({ preventScroll: true }); return () => { live = false; }; @@ -80,7 +126,18 @@ export default function EmojiPicker({ onPick, onClose }) { } return ( -
+
OpenEmoji by Profullstack, Inc. (CC BY 4.0)
diff --git a/src/lib/components/settings/ProfileSettings.jsx b/src/lib/components/settings/ProfileSettings.jsx index 62f535a3..29341ed1 100644 --- a/src/lib/components/settings/ProfileSettings.jsx +++ b/src/lib/components/settings/ProfileSettings.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAuthStore } from '@/lib/stores/auth.js'; import EmojiPicker from '@/lib/components/chat/EmojiPicker.jsx'; import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; @@ -14,6 +14,7 @@ export default function ProfileSettings() { const [form, setForm] = useState({ emoji: '', pronouns: '', website: '', bio: '' }); const [loaded, setLoaded] = useState(false); const [picking, setPicking] = useState(false); + const emojiBtnRef = useRef(null); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); @@ -59,7 +60,7 @@ export default function ProfileSettings() {
- {form.emoji && ( @@ -67,9 +68,12 @@ export default function ProfileSettings() { )} Shown next to your name. {picking && ( -
- { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} onClose={() => setPicking(false)} /> -
+ { setForm((f) => ({ ...f, emoji: char })); setPicking(false); }} + onClose={() => setPicking(false)} + /> )}
@@ -104,7 +108,6 @@ export default function ProfileSettings() { .ps-input { width: 100%; padding: .5rem .75rem; border: 1px solid var(--color-border-primary); border-radius: .375rem; background: var(--color-bg-primary); color: var(--color-text-primary); font-size: .875rem; font-family: inherit; } .ps-emoji { position: relative; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } .ps-emoji-btn { width: 2.75rem; height: 2.75rem; font-size: 1.6rem; line-height: 1; border: 1px solid var(--color-border-primary); border-radius: .5rem; background: var(--color-bg-primary); cursor: pointer; } - .ps-picker { position: absolute; top: 3rem; left: 0; z-index: 20; } .ps-hint { font-size: .8rem; color: var(--color-text-secondary); } .ps-alert { padding: .5rem .75rem; border-radius: .375rem; font-size: .85rem; } .ps-ok { background: rgba(16,185,129,.1); color: #065f46; } diff --git a/src/lib/emoji/openemoji.js b/src/lib/emoji/openemoji.js index da1ce961..78e60fd9 100644 --- a/src/lib/emoji/openemoji.js +++ b/src/lib/emoji/openemoji.js @@ -14,9 +14,12 @@ const PICTOGRAPHIC = /\p{Extended_Pictographic}|\p{Regional_Indicator}|⃣/u; const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; -/** OpenEmoji key of a character sequence: its codepoints, lowercase hex, hyphen-joined. */ +/** + * OpenEmoji key of a character sequence: its codepoints, lowercase hex padded + * to four digits ("0023-fe0f-20e3" for #️⃣, "00a9-fe0f" for ©️), hyphen-joined. + */ export function emojiKey(char) { - return [...char].map((c) => c.codePointAt(0).toString(16)).join('-'); + return [...char].map((c) => c.codePointAt(0).toString(16).padStart(4, '0')).join('-'); } /** diff --git a/src/lib/emoji/openemoji.test.js b/src/lib/emoji/openemoji.test.js new file mode 100644 index 00000000..aa5e78fb --- /dev/null +++ b/src/lib/emoji/openemoji.test.js @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { emojiKey, artworkKey } from './openemoji.js'; + +describe('emojiKey', () => { + it('pads codepoints to four hex digits like the OpenEmoji files', () => { + expect(emojiKey('😀')).toBe('1f600'); + expect(emojiKey('#️⃣')).toBe('0023-fe0f-20e3'); + expect(emojiKey('©️')).toBe('00a9-fe0f'); + expect(artworkKey('©')).toBe('00a9-fe0f'); + }); + + it('has an image for every emoji the picker lists', () => { + const index = JSON.parse(readFileSync('public/openemoji/index.json', 'utf8')); + const missing = index.emoji.filter(([char]) => !existsSync(`public/openemoji/64/${emojiKey(char)}.webp`)); + expect(missing.map(([c]) => c)).toEqual([]); + }); +}); diff --git a/src/lib/emoji/placement.js b/src/lib/emoji/placement.js new file mode 100644 index 00000000..1d46b76d --- /dev/null +++ b/src/lib/emoji/placement.js @@ -0,0 +1,50 @@ +/** + * Where to put a popover (the emoji picker) next to its anchor so it stays on + * screen: try the preferred side, then the opposite one, then right and left. + * Coordinates are viewport pixels, ready for position: fixed. + * + * anchor and bounds are { top, right, bottom, left }; size is { width, height }. + * align 'start' lines the popover up with the anchor's left (or top) edge, + * 'end' with its right (or bottom) edge; either way it is then nudged back + * inside bounds. When no side has room for the full height, it goes on the + * taller of above/below and maxHeight says how much it may use. + */ +const OPPOSITE = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + +export function placePopover({ anchor, size, bounds, prefer = 'bottom', align = 'start', gap = 8 }) { + const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi))); + const room = { + top: anchor.top - gap - bounds.top, + bottom: bounds.bottom - anchor.bottom - gap, + left: anchor.left - gap - bounds.left, + right: bounds.right - anchor.right - gap, + }; + const fits = (side) => + side === 'top' || side === 'bottom' + ? room[side] >= size.height && bounds.right - bounds.left >= size.width + : room[side] >= size.width && bounds.bottom - bounds.top >= size.height; + + const horizontal = (width) => + clamp(align === 'end' ? anchor.right - width : anchor.left, bounds.left, bounds.right - width); + const vertical = (height) => + clamp(align === 'end' ? anchor.bottom - height : anchor.top, bounds.top, bounds.bottom - height); + + const order = [prefer, OPPOSITE[prefer], 'right', 'left'].filter((s, i, a) => a.indexOf(s) === i); + const side = order.find(fits); + + if (side === 'top') return { side, top: anchor.top - gap - size.height, left: horizontal(size.width), maxHeight: null }; + if (side === 'bottom') return { side, top: anchor.bottom + gap, left: horizontal(size.width), maxHeight: null }; + if (side === 'right') return { side, top: vertical(size.height), left: anchor.right + gap, maxHeight: null }; + if (side === 'left') return { side, top: vertical(size.height), left: anchor.left - gap - size.width, maxHeight: null }; + + // Nothing fits whole (a short window): use the taller of above and below. + const up = room.top > room.bottom; + const maxHeight = Math.max(0, up ? room.top : room.bottom); + const height = Math.min(size.height, maxHeight); + return { + side: up ? 'top' : 'bottom', + top: up ? anchor.top - gap - height : anchor.bottom + gap, + left: horizontal(size.width), + maxHeight, + }; +} diff --git a/src/lib/emoji/placement.test.js b/src/lib/emoji/placement.test.js new file mode 100644 index 00000000..60276e22 --- /dev/null +++ b/src/lib/emoji/placement.test.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { placePopover } from './placement.js'; + +const bounds = { top: 64, left: 8, right: 1272, bottom: 792 }; // below a 64px navbar +const size = { width: 352, height: 380 }; +const at = (top, left, h = 44, w = 44) => ({ top, left, bottom: top + h, right: left + w }); + +describe('placePopover', () => { + it('opens below when preferred and there is room', () => { + const p = placePopover({ anchor: at(277, 81), size, bounds, prefer: 'bottom' }); + expect(p).toMatchObject({ side: 'bottom', top: 277 + 44 + 8, left: 81, maxHeight: null }); + }); + + it('flips below when above would run under the navbar (the settings bug)', () => { + const p = placePopover({ anchor: at(277, 81), size, bounds, prefer: 'top' }); + expect(p.side).toBe('bottom'); + expect(p.top).toBeGreaterThanOrEqual(bounds.top); + }); + + it('flips above near the bottom of the window (the composer)', () => { + const p = placePopover({ anchor: at(700, 300, 60, 600), size, bounds, prefer: 'bottom' }); + expect(p).toMatchObject({ side: 'top', top: 700 - 8 - 380 }); + }); + + it('goes to the side when neither above nor below has room', () => { + const p = placePopover({ anchor: at(300, 100, 300, 200), size, bounds, prefer: 'bottom' }); + expect(p.side).toBe('right'); + expect(p.left).toBe(100 + 200 + 8); + expect(p.top).toBeGreaterThanOrEqual(bounds.top); + expect(p.top + size.height).toBeLessThanOrEqual(bounds.bottom); + }); + + it('uses the left side when the anchor hugs the right edge', () => { + const p = placePopover({ anchor: at(300, 1000, 300, 260), size, bounds, prefer: 'bottom' }); + expect(p.side).toBe('left'); + expect(p.left).toBe(1000 - 8 - 352); + }); + + it('end-aligns and clamps horizontally inside the window', () => { + const p = placePopover({ anchor: at(100, 1200, 30, 60), size, bounds, prefer: 'bottom', align: 'end' }); + expect(p.left + size.width).toBeLessThanOrEqual(bounds.right); + const q = placePopover({ anchor: at(100, 0, 30, 60), size, bounds, prefer: 'bottom', align: 'end' }); + expect(q.left).toBe(bounds.left); + }); + + it('shrinks into the taller space when nothing fits whole', () => { + const short = { top: 0, left: 0, right: 360, bottom: 500 }; + const p = placePopover({ anchor: at(150, 4, 44, 352), size, bounds: short, prefer: 'top' }); + expect(p.side).toBe('bottom'); + expect(p.maxHeight).toBe(500 - 194 - 8); + expect(p.top).toBe(194 + 8); + }); +});