diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index d013c27..a85bb19 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -67,6 +67,9 @@ function slashRank(c: SlashCommand, q: string): number { 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[] = [ { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, { type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: , aliases: ['nummer', 'liste', 'ol'] }, @@ -209,6 +212,28 @@ export const OutlinerBlock: React.FC = ({ const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = useState(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(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(null); const debounceTimerRef = useRef(null); const latestBlockRef = useRef(block); @@ -1087,9 +1112,13 @@ export const OutlinerBlock: React.FC = ({ const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren; + const showBubble = hasHover ? pointerMoving || pointerOnBubble || showTodoMenu : activeFocus; + return (
= ({ {/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
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 ${ - activeFocus + showBubble ? 'opacity-100 pointer-events-auto' - : // Hover only with a real pointer: a tap on a phone would make the invisible buttons clickable - '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' + : // Hidden buttons must not take clicks; keyboard focus inside still shows it + '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 */}