Paket A: Vollständige Emoji-Auswahl für Notiz-Icons mit Lazy-Loading und Mobile Bottom-Sheet

This commit is contained in:
sandrodach committed 2026-10-28 22:44:21 +01:00
1 parent a354f3d008
commit 633bd6f1ef
5 files changed
+282 -132

No files matched your search

+22
View File
@@ -9,6 +9,7 @@
"version": "0.2.0",
"dependencies": {
"@types/prismjs": "^1.26.6",
"emoji-picker-react": "^4.12.0",
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
@@ -2101,6 +2102,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-picker-react": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz",
"integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==",
"license": "MIT",
"dependencies": {
"flairup": "1.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -2302,6 +2318,12 @@
"node": ">=8"
}
},
"node_modules/flairup": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"prismjs": "^1.30.0",
@@ -44,63 +44,7 @@ export interface DocumentEditorProps {
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
}
interface EmojiItem {
emoji: string;
category: string;
keywords: string;
}
const EMOJI_CATALOG: EmojiItem[] = [
// Notizen & Dokumente
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
// Ideen & Fokus
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
// Tech & Dev
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
// Alltag & Organisation
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
];
import { EmojiPickerModal } from './EmojiPickerModal';
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
if (!blocks || blocks.length === 0) {
@@ -250,21 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [emojiSearch, setEmojiSearch] = useState<string>('');
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null);
const [engineState, setEngineState] = useState<EngineState | null>(null);
const filteredEmojis = useMemo(() => {
const q = emojiSearch.trim().toLowerCase();
if (!q) return EMOJI_CATALOG;
return EMOJI_CATALOG.filter(
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
);
}, [emojiSearch]);
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
@@ -1044,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
<input
type="text"
autoFocus
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
placeholder="Icon suchen oder Emoji eingeben…"
className="input-field w-full pl-7 pr-2 py-1 text-xs"
/>
</div>
{doc?.icon && (
<button
type="button"
onClick={() => {
handleSelectIcon('');
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
<span>Icon entfernen</span>
</button>
)}
{emojiSearch.trim() && (
<button
type="button"
onClick={() => {
handleSelectIcon(emojiSearch.trim());
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
>
<span>Eingabe als Icon nutzen</span>
<span className="text-base">{emojiSearch.trim()}</span>
</button>
)}
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
{filteredEmojis.map((item) => (
<button
key={item.emoji}
type="button"
onClick={() => {
handleSelectIcon(item.emoji);
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
title={item.keywords}
>
{item.emoji}
</button>
))}
{filteredEmojis.length === 0 && (
<div className="col-span-6 text-center text-xs text-faint py-3">
Kein passendes Standard-Icon
</div>
)}
</div>
</div>
<EmojiPickerModal
currentIcon={doc?.icon}
onSelectEmoji={handleSelectIcon}
onClose={() => setShowEmojiPicker(false)}
/>
)}
</div>
@@ -0,0 +1,226 @@
import React, { Suspense, useEffect, useRef, useState } from 'react';
import { X, Trash2, Loader2, Clock } from 'lucide-react';
import type { EmojiClickData } from 'emoji-picker-react';
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
// Lazy-load emoji-picker-react so the main bundle size stays small
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
const MAX_RECENT_EMOJIS = 10;
function getStoredRecentEmojis(): string[] {
try {
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.slice(0, MAX_RECENT_EMOJIS);
}
} catch {
// fallback
}
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
}
function saveRecentEmoji(emoji: string): void {
try {
const current = getStoredRecentEmojis();
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
} catch {
// ignore
}
}
interface EmojiPickerModalProps {
currentIcon?: string;
onSelectEmoji: (emoji: string) => void;
onClose: () => void;
}
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
currentIcon,
onSelectEmoji,
onClose,
}) => {
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
const [isDark, setIsDark] = useState<boolean>(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
: true
);
const containerRef = useRef<HTMLDivElement>(null);
// Sync theme changes
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.classList.contains('dark'));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
});
return () => observer.disconnect();
}, []);
// Close on Escape or click outside (desktop popover)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handlePick = (emoji: string) => {
saveRecentEmoji(emoji);
setRecentEmojis(getStoredRecentEmojis());
onSelectEmoji(emoji);
};
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
handlePick(emojiData.emoji);
};
return (
<>
{/* Mobile Backdrop (< 640px) */}
<div
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
<div
ref={containerRef}
className="
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
"
style={
{
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
'--epr-bg-color': 'var(--bg-panel)',
'--epr-category-label-bg-color': 'var(--bg-panel)',
'--epr-picker-border-color': 'var(--border-color)',
'--epr-text-color': 'var(--text-main)',
'--epr-search-input-bg-color': 'var(--bg-app)',
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
'--epr-highlight-color': 'var(--accent-primary)',
'--epr-category-icon-active-color': 'var(--accent-primary)',
} as React.CSSProperties
}
>
{/* Mobile handle indicator */}
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
{/* Top Header: Title, Actions & Close */}
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
<span>Icon auswählen</span>
{currentIcon && (
<span className="text-base leading-none ml-1">{currentIcon}</span>
)}
</div>
<div className="flex items-center gap-1">
{currentIcon && (
<button
type="button"
onClick={() => onSelectEmoji('')}
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
title="Icon entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Entfernen</span>
</button>
)}
<button
type="button"
onClick={onClose}
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
title="Schließen"
aria-label="Schließen"
>
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button>
</div>
</div>
{/* Recent Emojis Bar */}
{recentEmojis.length > 0 && (
<div className="shrink-0 space-y-1.5">
<div className="flex items-center gap-1 text-[11px] text-muted">
<Clock className="w-3 h-3 text-accent" />
<span>Zuletzt verwendet:</span>
</div>
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
{recentEmojis.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handlePick(emoji)}
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
>
{emoji}
</button>
))}
</div>
</div>
)}
{/* Full Emoji Catalog via emoji-picker-react */}
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
<Suspense
fallback={
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
<Loader2 className="w-5 h-5 animate-spin text-accent" />
<span>Emoji-Katalog wird geladen…</span>
</div>
}
>
<EmojiPicker
onEmojiClick={handlePickerEmojiClick}
autoFocusSearch={true}
theme={isDark ? Theme.DARK : Theme.LIGHT}
emojiStyle={EmojiStyle.NATIVE}
suggestedEmojisMode={SuggestionMode.RECENT}
searchPlaceholder="Emoji suchen…"
searchPlaceHolder="Emoji suchen…"
width="100%"
height="350px"
previewConfig={{
showPreview: false,
}}
skinTonesDisabled={false}
lazyLoadEmojis={true}
/>
</Suspense>
</div>
</div>
</>
);
};
+27
View File
@@ -444,3 +444,30 @@ body {
color: #f97316;
}
}
/* ==========================================================================
EMOJI PICKER (emoji-picker-react Integration & Mobile Touch Tuning)
========================================================================== */
.mindsift-emoji-picker .EmojiPickerReact {
border: none !important;
font-family: inherit !important;
}
@media (max-width: 639px) {
/* Prevent iOS auto-zoom on input focus */
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
font-size: 1rem !important;
min-height: 44px !important;
}
/* Min 44px touch targets on mobile */
.mindsift-emoji-picker .EmojiPickerReact button.epr-emoji {
min-width: 44px !important;
min-height: 44px !important;
}
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
min-height: 44px !important;
}
}