feat(workspace): bullet action bubble only while the mouse moves

With a real pointer the bubble (zoom, extract, delete, to-do menu, type)
shows while the mouse moves over the row and for a second after; it
stays while the mouse rests on it, its to-do menu is open or a button
in it has keyboard focus. A resting mouse, also while typing, no longer
leaves it over the text. Touch devices keep showing it at the focused
bullet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 21:14:42 +01:00
1 parent 426b548fc7
commit 2f872c0590
1 file changed
+34 -3
@@ -67,6 +67,9 @@ function slashRank(c: SlashCommand, q: string): number {
return 4; return 4;
} }
// How long the action bubble stays after the last mouse move over a bullet
const BUBBLE_IDLE_MS = 1000;
const SLASH_COMMANDS: SlashCommand[] = [ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> }, { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] }, { type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
@@ -209,6 +212,28 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
// Action bubble with a real pointer: shown only while the mouse moves over the row (until a moment after
// the last move), while it rests on the bubble or a menu of it is open; a resting mouse must not leave it
// over the text. Touch devices keep showing it at the focused bullet.
const hasHover = useMemo(() => typeof window !== 'undefined' && !!window.matchMedia?.('(hover: hover)').matches, []);
const [pointerMoving, setPointerMoving] = useState(false);
const [pointerOnBubble, setPointerOnBubble] = useState(false);
const pointerIdleTimerRef = useRef<number | null>(null);
const handleRowMouseMove = () => {
if (!hasHover) return;
setPointerMoving(true);
if (pointerIdleTimerRef.current !== null) window.clearTimeout(pointerIdleTimerRef.current);
pointerIdleTimerRef.current = window.setTimeout(() => setPointerMoving(false), BUBBLE_IDLE_MS);
};
const handleRowMouseLeave = () => {
if (pointerIdleTimerRef.current !== null) window.clearTimeout(pointerIdleTimerRef.current);
pointerIdleTimerRef.current = null;
setPointerMoving(false);
};
useEffect(() => () => {
if (pointerIdleTimerRef.current !== null) window.clearTimeout(pointerIdleTimerRef.current);
}, []);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null); const debounceTimerRef = useRef<number | null>(null);
const latestBlockRef = useRef<DocumentBlock>(block); const latestBlockRef = useRef<DocumentBlock>(block);
@@ -1087,9 +1112,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren; const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
const showBubble = hasHover ? pointerMoving || pointerOnBubble || showTodoMenu : activeFocus;
return ( return (
<div <div
data-outline-block={block.id} data-outline-block={block.id}
onMouseMove={handleRowMouseMove}
onMouseLeave={handleRowMouseLeave}
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${ className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
inRangeSelection ? 'bg-accent/15' : '' inRangeSelection ? 'bg-accent/15' : ''
} ${ } ${
@@ -1595,11 +1624,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */} {/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
<div <div
onMouseEnter={() => setPointerOnBubble(true)}
onMouseLeave={() => setPointerOnBubble(false)}
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${ className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
activeFocus showBubble
? 'opacity-100 pointer-events-auto' ? 'opacity-100 pointer-events-auto'
: // Hover only with a real pointer: a tap on a phone would make the invisible buttons clickable : // Hidden buttons must not take clicks; keyboard focus inside still shows it
'opacity-0 [@media(hover:hover)]:group-hover:opacity-100 focus-within:opacity-100 pointer-events-none [@media(hover:hover)]:group-hover:pointer-events-auto focus-within:pointer-events-auto' 'opacity-0 pointer-events-none focus-within:opacity-100 focus-within:pointer-events-auto'
}`} }`}
> >
{/* Convert back to bullet for headings, callouts, dividers, code and numbered items */} {/* Convert back to bullet for headings, callouts, dividers, code and numbered items */}