Files
odysseus/static/js/emojiPicker.js
T
Max Hsu fef08ed114 fix(modal): keep body-portaled dropdowns above their tool modal at any stack depth (#4720) (#4724)
* fix(memory): keep the Brain memory item menu above the modal at any stack depth

The memory item "⋮" dropdown is portaled to <body> with a hardcoded
z-index of 10001. Tool modals, however, get a monotonically increasing
z-index from modalManager's bring-to-front counter (_modalTopZ), which
climbs unbounded as modals are opened/restored over a session. Once that
counter passes 10001, the Brain modal stacks above the body-portaled
dropdown, so the menu renders behind the panel — visible only where it
spills past the modal's edge (#4720).

Derive the dropdown's z-index from the owning modal's current z-index
(+1), keeping 10001 as a floor for the common low-counter case, so the
menu always sits just above its modal however high the counter has climbed.

Verified with document.elementFromPoint at the dropdown's location: with a
high modal z-index the old build returns the modal at every sampled point
(menu behind); the fixed build returns the dropdown (menu on top). The
default low-counter case is unchanged (z stays 10001).

* refactor(modal): route body-portaled dropdowns through a shared topPortalZ() helper

The hardcoded z-index:10001 the Brain memory menu used (#4720) is the same
literal shared by ~16 body-portaled dropdowns across calendar, cookbook,
cookbookServe, documentLibrary, emailLibrary, gallery, notes, emojiPicker and
memory — each renders behind its owning tool modal once modalManager's
bring-to-front counter climbs past the literal over a long session.

Promote the per-dropdown fix into a single topPortalZ() helper in
toolWindowZOrder.js — the existing source of truth for tool-window z, already
imported by modalManager's _bringToFront and notes.js — returning
max(topToolWindowZ(), dock-chip floor) + 1, so a portaled dropdown always sits
just above the live tool-window stack however high the counter has climbed.
Route all 16 sites through it. The slashCommands tour tooltips and the
cookbookServe VRAM dialog are intentionally left out (neither is a modal-owned
portaled dropdown).

Add tests/test_portal_dropdown_z_js.py covering the helper, including the #4720
scenario (modal counter at 99999 -> dropdown at 100000). Existing
test_notes_z_order_js.py stays green.
2026-06-23 10:24:31 +02:00

314 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* emojiPicker.js — Monochrome icon picker (no colored emojis).
* Curated set of common icons as inline SVGs. The PICKER shows monochrome SVGs,
* and — crucially — every character it INSERTS is one with a real monochrome
* (text) presentation. On insert we append U+FE0E (VARIATION SELECTOR-15) so the
* glyph renders flat/text, not as a system color emoji — so the RECIPIENT of an
* email/message sees a non-colored symbol too, not just the sender. Pure-emoji
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
*/
import { topPortalZ } from './toolWindowZOrder.js';
// Each entry: [char, label, svgPath OR svg]
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
// Text variation selector — appended to chars that might render as color emoji,
// asks the browser to use text (monochrome) presentation if available.
const VS15 = '\uFE0E';
const EMOJI_GROUPS = [
{
name: 'Faces & Hearts',
// Only chars with a genuine monochrome (text) presentation. VS15 is appended
// on insert (see _insertEmoji) so they render flat for the recipient too.
// Pure-emoji faces (grin/cry/sunglasses/thumbs) have no text form, so they're
// omitted — there is no way to send them non-colored as plain text.
items: [
['☻', 'grin', I('<circle cx="12" cy="12" r="10"/><path d="M7 14 C 7 18, 17 18, 17 14 Z"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>')],
['♡', 'heart-outline', I('<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>')],
['★', 'star', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" fill="currentColor" stroke="none"/>')],
['☆', 'star-outline', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['✦', 'sparkle', I('<polygon points="12 2 14 10 22 12 14 14 12 22 10 14 2 12 10 10" fill="currentColor" stroke="none"/>')],
['☽', 'moon', I('<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>')],
],
},
{
name: 'Checks & Marks',
items: [
['✓', 'check', I('<polyline points="20 6 9 17 4 12"/>')],
['✗', 'cross', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['✘', 'cross-heavy', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['★', 'star-filled', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['☆', 'star-empty', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['●', 'dot', I('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')],
['○', 'circle', I('<circle cx="12" cy="12" r="8"/>')],
['■', 'square-filled', I('<rect x="6" y="6" width="12" height="12" fill="currentColor" stroke="none"/>')],
['□', 'square-empty', I('<rect x="5" y="5" width="14" height="14"/>')],
['◆', 'diamond', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
['◇', 'diamond-empty', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
['†', 'dagger', I('<line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="8" x2="16" y2="8"/>')],
],
},
{
name: 'Arrows',
items: [
['→', 'arrow-right', I('<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>')],
['←', 'arrow-left', I('<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>')],
['↑', 'arrow-up', I('<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>')],
['↓', 'arrow-down', I('<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>')],
['⇒', 'arrow-r-dbl', I('<polyline points="10 5 17 12 10 19"/><polyline points="6 5 13 12 6 19"/>')],
['⇐', 'arrow-l-dbl', I('<polyline points="14 5 7 12 14 19"/><polyline points="18 5 11 12 18 19"/>')],
],
},
{
name: 'Math & Punctuation',
items: [
['±', 'plus-minus', I('<line x1="4" y1="10" x2="20" y2="10"/><line x1="12" y1="2" x2="12" y2="18"/><line x1="4" y1="20" x2="20" y2="20"/>')],
['×', 'multiply', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['÷', 'divide', I('<circle cx="12" cy="6" r="1.5" fill="currentColor" stroke="none"/><line x1="5" y1="12" x2="19" y2="12"/><circle cx="12" cy="18" r="1.5" fill="currentColor" stroke="none"/>')],
['≈', 'approx', I('<path d="M4 9 C 6 6, 8 12, 10 9 S 14 6, 16 9 S 20 12, 22 9"/><path d="M4 15 C 6 12, 8 18, 10 15 S 14 12, 16 15 S 20 18, 22 15"/>')],
['≠', 'not-equal', I('<line x1="5" y1="9" x2="19" y2="9"/><line x1="5" y1="15" x2="19" y2="15"/><line x1="16" y1="5" x2="8" y2="19"/>')],
['≤', 'lte', I('<polyline points="17 5 7 11 17 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
['≥', 'gte', I('<polyline points="7 5 17 11 7 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
['∞', 'infinity', I('<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.585 0-4.585 8 0 8 5.606 0 7.644-8 12.739-8z"/>')],
['π', 'pi', I('<line x1="4" y1="8" x2="20" y2="8"/><line x1="9" y1="8" x2="9" y2="20"/><line x1="15" y1="8" x2="15" y2="20"/>')],
['Σ', 'sum', I('<polyline points="6 4 18 4 10 12 18 20 6 20"/>')],
['∆', 'delta', I('<polygon points="12 4 20 20 4 20"/>')],
['√', 'root', I('<polyline points="4 14 8 20 14 4 22 4"/>')],
['°', 'degree', I('<circle cx="12" cy="8" r="3"/>')],
['§', 'section', I('<path d="M14 6 a4 3 0 1 0 -4 4 q-3 0 -3 3 t3 3 q3 0 3 -3"/>')],
['¶', 'pilcrow', I('<path d="M16 4 H 9 a4 4 0 0 0 0 8 H 12 V 20"/><line x1="16" y1="4" x2="16" y2="20"/>')],
['•', 'bullet', I('<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>')],
['…', 'ellipsis', I('<circle cx="6" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="12" r="1.5" fill="currentColor" stroke="none"/>')],
['—', 'em-dash', I('<line x1="4" y1="12" x2="20" y2="12"/>')],
['«', 'quote-l', I('<polyline points="12 5 6 12 12 19"/><polyline points="18 5 12 12 18 19"/>')],
['»', 'quote-r', I('<polyline points="6 5 12 12 6 19"/><polyline points="12 5 18 12 12 19"/>')],
['"', 'quote-dbl', I('<line x1="8" y1="5" x2="8" y2="11"/><line x1="11" y1="5" x2="11" y2="11"/><line x1="13" y1="5" x2="13" y2="11"/><line x1="16" y1="5" x2="16" y2="11"/>')],
],
},
{
name: 'Currency & Misc',
items: [
['€', 'euro', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">€</text>')],
['£', 'pound', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">£</text>')],
['¥', 'yen', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">¥</text>')],
['$', 'dollar', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">$</text>')],
['¢', 'cent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">¢</text>')],
['%', 'percent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">%</text>')],
['‰', 'per-mille', I('<text x="12" y="16" font-size="13" text-anchor="middle" fill="currentColor" stroke="none">‰</text>')],
['№', 'number', I('<text x="12" y="16" font-size="12" text-anchor="middle" fill="currentColor" stroke="none">№</text>')],
],
},
];
let _pickerEl = null;
let _pickerOpenedAt = 0;
let _targetEl = null;
let _closeOnOutsideClick = null;
let _closeOnEscape = null;
// For contenteditable targets we snapshot the caret/selection when the picker
// opens, since focusing the picker's search box collapses the live selection.
let _savedRange = null;
// `target` may be a textarea element id (string) or a resolver function that
// returns the live target element — the latter lets a caller switch between a
// textarea and a contenteditable (e.g. plain markdown vs. WYSIWYG email).
export function createEmojiButton(target) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-btn';
btn.title = 'Insert icon';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>';
// Don't steal focus from the editor on press — keeps the caret/selection so
// the emoji lands where the user was typing.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const el = (typeof target === 'function') ? target() : document.getElementById(target);
if (!el) return;
togglePicker(btn, el);
});
return btn;
}
function togglePicker(anchor, target) {
const now = Date.now();
if (_pickerEl) {
// Ignore the duplicate/ghost click mobile fires right after opening, which
// would otherwise re-toggle the picker shut the instant it appears.
if (now - _pickerOpenedAt < 400) return;
_closePicker();
return;
}
_targetEl = target;
_savedRange = null;
if (target.isContentEditable) {
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const r = sel.getRangeAt(0);
if (target.contains(r.commonAncestorContainer)) _savedRange = r.cloneRange();
}
}
_pickerEl = _buildPicker();
_pickerOpenedAt = now;
document.body.appendChild(_pickerEl);
const rect = anchor.getBoundingClientRect();
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => {
const pr = _pickerEl.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) {
_pickerEl.style.left = Math.max(8, window.innerWidth - pr.width - 8) + 'px';
}
// Always open downward. If it would run past the bottom, cap its height so
// it scrolls internally instead of flipping up (which got cut off at top).
const avail = window.innerHeight - rect.bottom - 12;
if (pr.height > avail) {
_pickerEl.style.maxHeight = Math.max(160, avail) + 'px';
}
});
const close = (e) => {
// Ignore the ghost/duplicate click mobile fires right after opening.
if (e && e.type === 'click' && Date.now() - _pickerOpenedAt < 400) return;
if (_pickerEl && !_pickerEl.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) {
_closePicker();
}
};
_closeOnOutsideClick = close;
setTimeout(() => document.addEventListener('click', close, true), 10);
_closeOnEscape = (e) => {
if (e.key !== 'Escape' || !_pickerEl) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
_closePicker();
};
document.addEventListener('keydown', _closeOnEscape, true);
}
function _closePicker() {
if (_pickerEl) {
_pickerEl.remove();
_pickerEl = null;
}
if (_closeOnOutsideClick) {
document.removeEventListener('click', _closeOnOutsideClick, true);
_closeOnOutsideClick = null;
}
if (_closeOnEscape) {
document.removeEventListener('keydown', _closeOnEscape, true);
_closeOnEscape = null;
}
}
function _buildPicker() {
const el = document.createElement('div');
el.className = 'emoji-picker';
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Search…';
search.className = 'emoji-picker-search';
el.appendChild(search);
const groupsContainer = document.createElement('div');
groupsContainer.className = 'emoji-picker-groups';
el.appendChild(groupsContainer);
function render(filter = '') {
groupsContainer.innerHTML = '';
const f = filter.toLowerCase();
for (const group of EMOJI_GROUPS) {
const filtered = f
? group.items.filter(item => item[1].toLowerCase().includes(f) || item[0].includes(filter))
: group.items;
if (filtered.length === 0) continue;
const groupDiv = document.createElement('div');
groupDiv.className = 'emoji-picker-group';
const header = document.createElement('div');
header.className = 'emoji-picker-group-name';
header.textContent = group.name;
groupDiv.appendChild(header);
const grid = document.createElement('div');
grid.className = 'emoji-picker-grid';
for (const item of filtered) {
const [char, label, svg] = item;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-item';
btn.title = label;
btn.innerHTML = svg;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
_insertEmoji(char);
_closePicker();
});
grid.appendChild(btn);
}
groupDiv.appendChild(grid);
groupsContainer.appendChild(groupDiv);
}
}
render();
search.addEventListener('input', () => render(search.value.trim()));
setTimeout(() => search.focus(), 50);
return el;
}
function _insertEmoji(char) {
if (!_targetEl) return;
// Force monochrome (text) presentation for the recipient by appending the
// text variation selector U+FE0E. It only affects chars that *have* an emoji
// presentation (e.g. ♥ ▶ ❤ ↩ ☀); for plain ASCII it's pointless, so we skip
// those. This is why the inserted glyph is non-colored on the other end too,
// not just in our own (already-SVG) picker UI.
const cp = char.codePointAt(0);
const ins = cp >= 0x80 ? char + VS15 : char;
// Contenteditable (e.g. WYSIWYG email body) — insert at the saved caret.
if (_targetEl.isContentEditable) {
_targetEl.focus();
let range = _savedRange;
if (!range) {
range = document.createRange();
range.selectNodeContents(_targetEl);
range.collapse(false);
}
range.deleteContents();
const node = document.createTextNode(ins);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
_savedRange = range.cloneRange();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const start = _targetEl.selectionStart || 0;
const end = _targetEl.selectionEnd || 0;
const before = _targetEl.value.substring(0, start);
const after = _targetEl.value.substring(end);
_targetEl.value = before + ins + after;
_targetEl.selectionStart = _targetEl.selectionEnd = start + ins.length;
_targetEl.focus();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
}
export default { createEmojiButton };