feat: emoji picker in create dialogs; due date and priority right under a deepened task; no flicker on kanban drop
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fa02e8dee6
commit
7860609201
13 files changed
+143
-74
No files matched your search
@@ -19,6 +19,7 @@ import { CountBadge } from '../../components/CountBadge';
|
||||
import { OriginBadge } from '../../components/OriginBadge';
|
||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
@@ -1203,14 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="🛒" />
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
|
||||
@@ -924,9 +925,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const renderDropSlot = (colId: string, index: number) => (
|
||||
<motion.div
|
||||
key={`slot-${colId}-${index}`}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: dragHeightRef.current, opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
variants={{
|
||||
closed: { height: 0, opacity: 0 },
|
||||
open: { height: dragHeightRef.current, opacity: 1 },
|
||||
// On a drop the card takes the gap's place at once, so the gap goes without closing first
|
||||
gone: (dropped: boolean) => ({ height: 0, opacity: 0, transition: { duration: dropped ? 0 : 0.2 } }),
|
||||
}}
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="gone"
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
@@ -967,6 +974,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const slot = kanbanSlot;
|
||||
resetKanbanDrag();
|
||||
if (!sourceId) return;
|
||||
// Set with the move in one render: the card shows up marked in its new place, not a moment later
|
||||
setJustDroppedId(sourceId);
|
||||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||||
const others = colTasks.filter((t) => t.id !== sourceId);
|
||||
if (others.length === 0 || !slot || slot.col !== colId) {
|
||||
await moveTaskToColumn(sourceId, colId);
|
||||
@@ -975,8 +985,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
} else {
|
||||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
|
||||
}
|
||||
setJustDroppedId(sourceId);
|
||||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||||
};
|
||||
|
||||
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
|
||||
@@ -2285,14 +2293,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newListIcon}
|
||||
onChange={(e) => setNewListIcon(e.target.value)}
|
||||
maxLength={2}
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
title="Icon (Emoji)"
|
||||
/>
|
||||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" />
|
||||
<input
|
||||
type="text"
|
||||
value={newListTitle}
|
||||
@@ -2458,13 +2459,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<AnimatePresence initial={false}>
|
||||
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||||
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
draggable={canDragTasks && !readOnly}
|
||||
onDragStart={(e) => {
|
||||
handleTaskDragStart(e, task);
|
||||
setJustDroppedId(null);
|
||||
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
||||
// Hidden a tick later: hiding the source right away would cancel the drag
|
||||
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
||||
@@ -2481,7 +2483,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||
} ${
|
||||
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60 animate-in fade-in zoom-in-95 duration-300' : ''}`}
|
||||
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
@@ -2629,7 +2631,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<AnimatePresence initial={false}>
|
||||
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||||
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
@@ -25,12 +26,16 @@ interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
||||
// opens below this button on desktop
|
||||
anchor?: DOMRect | null;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
currentIcon,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
anchor,
|
||||
}) => {
|
||||
const { inset, visibleHeight } = useKeyboardInset();
|
||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
@@ -61,6 +66,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // only the picker closes, not the dialog it was opened from
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -74,10 +80,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
@@ -90,11 +96,18 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
handlePick(emojiData.emoji);
|
||||
};
|
||||
|
||||
return (
|
||||
const anchorStyle = anchor
|
||||
? ({
|
||||
'--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 372))}px`,
|
||||
'--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 532)}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const picker = (
|
||||
<>
|
||||
{/* Mobile Backdrop (< 640px) */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
||||
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -102,12 +115,17 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="
|
||||
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] 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
|
||||
"
|
||||
className={`
|
||||
fixed inset-x-0 bottom-[var(--kb-inset)] rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||
sm:inset-auto sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl ${
|
||||
anchor
|
||||
? 'z-[61] sm:fixed sm:left-[var(--pick-left)] sm:top-[max(12px,var(--pick-top))]'
|
||||
: 'z-50 sm:absolute sm:left-0 sm:top-full sm:mt-1 sm:z-40'
|
||||
}
|
||||
`}
|
||||
style={
|
||||
{
|
||||
...anchorStyle,
|
||||
// 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)',
|
||||
@@ -193,4 +211,5 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return anchor ? createPortal(picker, document.body) : picker;
|
||||
};
|
||||
@@ -57,6 +57,7 @@ import { JournalCalendar } from './JournalCalendar';
|
||||
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
@@ -2968,14 +2969,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newNoteIcon}
|
||||
onChange={(e) => setNewNoteIcon(e.target.value)}
|
||||
aria-label="Emoji"
|
||||
title="Icon (Emoji)"
|
||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||
/>
|
||||
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="📝" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
|
||||
Reference in new issue
Block a user