Fix mobile tag input dropdown covered by onscreen keyboard with upward dropup popover
This commit is contained in:
1 parent
322cbea864
commit
67468d9fac
2 files changed
+229
-28
No files matched your search
@@ -29,6 +29,7 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
maximumScale: 1,
|
maximumScale: 1,
|
||||||
userScalable: false,
|
userScalable: false,
|
||||||
|
interactiveWidget: 'resizes-content',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -244,8 +244,12 @@ export default function EvercloneWorkspace() {
|
|||||||
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
||||||
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
||||||
|
|
||||||
// Tag input in bottom bar
|
// Tag input in bottom bar & autocomplete popover
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [tagDropdownOpen, setTagDropdownOpen] = useState(false);
|
||||||
|
const [highlightedTagIndex, setHighlightedTagIndex] = useState(-1);
|
||||||
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Quick creation dialogs in sidebar
|
// Quick creation dialogs in sidebar
|
||||||
const [creatingNotebook, setCreatingNotebook] = useState(false);
|
const [creatingNotebook, setCreatingNotebook] = useState(false);
|
||||||
@@ -1105,12 +1109,13 @@ export default function EvercloneWorkspace() {
|
|||||||
const clean = rawTagName.trim();
|
const clean = rawTagName.trim();
|
||||||
if (!clean || !activeNote) return;
|
if (!clean || !activeNote) return;
|
||||||
const existingNames = activeNote.tags.map((t) => t.name);
|
const existingNames = activeNote.tags.map((t) => t.name);
|
||||||
|
setTagInput('');
|
||||||
|
setTagDropdownOpen(false);
|
||||||
|
setHighlightedTagIndex(-1);
|
||||||
if (existingNames.some((n) => n.toLowerCase() === clean.toLowerCase())) {
|
if (existingNames.some((n) => n.toLowerCase() === clean.toLowerCase())) {
|
||||||
setTagInput('');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updatedNames = [...existingNames, clean];
|
const updatedNames = [...existingNames, clean];
|
||||||
setTagInput('');
|
|
||||||
await handleImmediatePatch({ tagNames: updatedNames });
|
await handleImmediatePatch({ tagNames: updatedNames });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1122,6 +1127,94 @@ export default function EvercloneWorkspace() {
|
|||||||
await handleImmediatePatch({ tagNames: updatedNames });
|
await handleImmediatePatch({ tagNames: updatedNames });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close tag dropup when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tagDropdownOpen) return;
|
||||||
|
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (
|
||||||
|
tagDropdownRef.current &&
|
||||||
|
!tagDropdownRef.current.contains(target) &&
|
||||||
|
tagInputRef.current &&
|
||||||
|
!tagInputRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setTagDropdownOpen(false);
|
||||||
|
setHighlightedTagIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick);
|
||||||
|
document.addEventListener('touchstart', handleOutsideClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
|
document.removeEventListener('touchstart', handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [tagDropdownOpen]);
|
||||||
|
|
||||||
|
// Compute available tags for autocomplete (excluding tags already present on activeNote)
|
||||||
|
const availableTags = useMemo(() => {
|
||||||
|
if (!activeNote) return [];
|
||||||
|
const currentTagNames = new Set(
|
||||||
|
activeNote.tags.map((t) => t.name.toLowerCase())
|
||||||
|
);
|
||||||
|
const unselected = sidebar.tags.filter(
|
||||||
|
(t) => !currentTagNames.has(t.name.toLowerCase())
|
||||||
|
);
|
||||||
|
if (!tagInput.trim()) {
|
||||||
|
return unselected;
|
||||||
|
}
|
||||||
|
const query = tagInput.trim().toLowerCase();
|
||||||
|
return unselected.filter((t) => t.name.toLowerCase().includes(query));
|
||||||
|
}, [activeNote, sidebar.tags, tagInput]);
|
||||||
|
|
||||||
|
const hasExactMatch = useMemo(() => {
|
||||||
|
const query = tagInput.trim().toLowerCase();
|
||||||
|
if (!query || !activeNote) return false;
|
||||||
|
return (
|
||||||
|
activeNote.tags.some((t) => t.name.toLowerCase() === query) ||
|
||||||
|
sidebar.tags.some((t) => t.name.toLowerCase() === query)
|
||||||
|
);
|
||||||
|
}, [tagInput, activeNote, sidebar.tags]);
|
||||||
|
|
||||||
|
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const showCreateOption = !hasExactMatch && Boolean(tagInput.trim());
|
||||||
|
const totalItems = availableTags.length + (showCreateOption ? 1 : 0);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!tagDropdownOpen) {
|
||||||
|
setTagDropdownOpen(true);
|
||||||
|
setHighlightedTagIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlightedTagIndex((prev) => (prev + 1 < totalItems ? prev + 1 : 0));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!tagDropdownOpen) {
|
||||||
|
setTagDropdownOpen(true);
|
||||||
|
setHighlightedTagIndex(totalItems - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlightedTagIndex((prev) => (prev - 1 >= 0 ? prev - 1 : totalItems - 1));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (tagDropdownOpen && highlightedTagIndex >= 0) {
|
||||||
|
if (showCreateOption && highlightedTagIndex === 0) {
|
||||||
|
handleAddTagToNote(tagInput.trim());
|
||||||
|
} else {
|
||||||
|
const tagIdx = showCreateOption ? highlightedTagIndex - 1 : highlightedTagIndex;
|
||||||
|
if (availableTags[tagIdx]) {
|
||||||
|
handleAddTagToNote(availableTags[tagIdx].name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (tagInput.trim()) {
|
||||||
|
handleAddTagToNote(tagInput.trim());
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTagDropdownOpen(false);
|
||||||
|
setHighlightedTagIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNotebook = async (e: React.FormEvent) => {
|
const handleCreateNotebook = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newNotebookName.trim()) return;
|
if (!newNotebookName.trim()) return;
|
||||||
@@ -3650,7 +3743,7 @@ export default function EvercloneWorkspace() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom Tag Bar */}
|
{/* Bottom Tag Bar */}
|
||||||
<footer className="px-6 py-2.5 border-t border-gray-200 bg-white flex flex-wrap items-center gap-1.5 text-xs">
|
<footer className="px-3 sm:px-6 py-2.5 border-t border-gray-200 bg-white flex flex-wrap items-center gap-1.5 text-xs relative">
|
||||||
<TagIcon className="w-3.5 h-3.5 text-gray-400 mr-1 shrink-0" />
|
<TagIcon className="w-3.5 h-3.5 text-gray-400 mr-1 shrink-0" />
|
||||||
{activeNote.tags.map((t) => (
|
{activeNote.tags.map((t) => (
|
||||||
<span
|
<span
|
||||||
@@ -3671,31 +3764,138 @@ export default function EvercloneWorkspace() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!activeNote.isTrashed && (
|
{!activeNote.isTrashed && (
|
||||||
<form
|
<div className="relative inline-flex items-center" ref={tagDropdownRef}>
|
||||||
onSubmit={(e) => {
|
<form
|
||||||
e.preventDefault();
|
onSubmit={(e) => {
|
||||||
handleAddTagToNote(tagInput);
|
e.preventDefault();
|
||||||
}}
|
if (tagInput.trim()) {
|
||||||
className="inline-flex items-center"
|
handleAddTagToNote(tagInput);
|
||||||
>
|
}
|
||||||
<input
|
|
||||||
id="everclone-add-tag-input"
|
|
||||||
type="text"
|
|
||||||
list="everclone-tags-datalist"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (tagInput.trim()) handleAddTagToNote(tagInput);
|
|
||||||
}}
|
}}
|
||||||
placeholder="+ Add tag…"
|
className="inline-flex items-center"
|
||||||
className="px-2 py-0.5 text-xs text-gray-700 placeholder-gray-400 focus:outline-none focus:bg-gray-100 rounded"
|
>
|
||||||
/>
|
<input
|
||||||
<datalist id="everclone-tags-datalist">
|
ref={tagInputRef}
|
||||||
{sidebar.tags.map((t) => (
|
id="everclone-add-tag-input"
|
||||||
<option key={t.id} value={t.name} />
|
type="text"
|
||||||
))}
|
value={tagInput}
|
||||||
</datalist>
|
onChange={(e) => {
|
||||||
</form>
|
setTagInput(e.target.value);
|
||||||
|
setTagDropdownOpen(true);
|
||||||
|
setHighlightedTagIndex(-1);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setTagDropdownOpen(true);
|
||||||
|
setHighlightedTagIndex(-1);
|
||||||
|
setTimeout(() => {
|
||||||
|
tagInputRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleTagInputKeyDown}
|
||||||
|
placeholder="+ Add tag…"
|
||||||
|
className="px-2 py-0.5 text-xs text-gray-700 placeholder-gray-400 focus:outline-none focus:bg-gray-100 rounded border border-transparent focus:border-gray-300 w-24 sm:w-28 focus:w-36 transition-all"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{tagDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full mb-2 left-0 z-50 w-64 max-w-[calc(100vw-2.5rem)] bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden flex flex-col"
|
||||||
|
style={{ maxHeight: '240px' }}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-[11px] font-semibold text-gray-500 shrink-0">
|
||||||
|
<span>
|
||||||
|
{tagInput.trim() ? 'Matching Tags' : 'Select or Create Tag'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTagDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-gray-600 p-0.5 rounded"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 divide-y divide-gray-100">
|
||||||
|
{/* New tag creation option */}
|
||||||
|
{!hasExactMatch && tagInput.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTagToNote(tagInput.trim());
|
||||||
|
}}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTagToNote(tagInput.trim());
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-xs transition ${
|
||||||
|
highlightedTagIndex === 0
|
||||||
|
? 'bg-emerald-100 text-emerald-900 font-semibold'
|
||||||
|
: 'bg-emerald-50/70 text-emerald-800 hover:bg-emerald-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
Create new tag <strong>"{tagInput.trim()}"</strong>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing available tags */}
|
||||||
|
{availableTags.map((tag, idx) => {
|
||||||
|
const itemIndex =
|
||||||
|
!hasExactMatch && tagInput.trim() ? idx + 1 : idx;
|
||||||
|
const isHighlighted = itemIndex === highlightedTagIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tag.id}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTagToNote(tag.name);
|
||||||
|
}}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTagToNote(tag.name);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 text-left text-xs transition ${
|
||||||
|
isHighlighted
|
||||||
|
? 'bg-emerald-50 text-emerald-900 font-medium'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<TagIcon className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
</div>
|
||||||
|
{tag.noteCount !== undefined && (
|
||||||
|
<span className="text-[10px] text-gray-400 shrink-0 ml-2">
|
||||||
|
{tag.noteCount} {tag.noteCount === 1 ? 'note' : 'notes'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{availableTags.length === 0 &&
|
||||||
|
(!tagInput.trim() || hasExactMatch) && (
|
||||||
|
<div className="px-3 py-3 text-center text-xs text-gray-400 italic">
|
||||||
|
{hasExactMatch
|
||||||
|
? 'Tag already added to note'
|
||||||
|
: 'No existing tags. Type to create one!'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in new issue
Block a user