From 1bd38f4d50c6f79ab5a35a205c8591ec95c780aa Mon Sep 17 00:00:00 2001 From: Sandro Date: Wed, 28 Oct 2026 20:26:55 +0100 Subject: [PATCH] feat(notes): presence as avatar stack, drop guide lines, fix stuck bullet focus - Leaving a block now clears its focus, so the bullet no longer stays highlighted - Remove the vertical indentation guide lines - Who has the note open: up to 3 overlapping avatars, "+n", full list on hover - Shared AvatarStack component; UserAvatar gets xxs size and optional title Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AvatarStack.tsx | 60 +++++++++++++++++++ frontend/src/components/UserAvatar.tsx | 7 ++- .../src/features/workspace/DocumentEditor.tsx | 27 ++++++--- .../src/features/workspace/OutlinerBlock.tsx | 13 +--- .../src/features/workspace/OutlinerTree.tsx | 7 +++ .../features/workspace/PresenceAvatars.tsx | 41 +++++++++++++ 6 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/AvatarStack.tsx create mode 100644 frontend/src/features/workspace/PresenceAvatars.tsx diff --git a/frontend/src/components/AvatarStack.tsx b/frontend/src/components/AvatarStack.tsx new file mode 100644 index 0000000..1d0e46b --- /dev/null +++ b/frontend/src/components/AvatarStack.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { UserAvatar } from './UserAvatar'; + +export interface AvatarPerson { + id: string; + name: string; + avatarUrl?: string; +} + +interface AvatarStackProps { + people: AvatarPerson[]; + max?: number; + size?: 'xxs' | 'xs'; + // Ring in the color of the surface behind the stack, so overlapping circles stay separated + ringClass?: string; + title?: string; + className?: string; +} + +const OVERFLOW_SIZE = { + xxs: 'w-4 h-4 text-[7px]', + xs: 'w-5 h-5 text-[9px]', +}; + +// Small overlapping profile pictures: at most `max` circles, the rest as a monochrome "+n" circle. +// Used for presence in notes, list/task-list tabs, task assignees and list item authors (Doku/ui-patterns.md). +export const AvatarStack: React.FC = ({ + people, + max = 3, + size = 'xs', + ringClass = 'ring-[var(--bg-app)]', + title, + className = '', +}) => { + if (people.length === 0) return null; + const visible = people.slice(0, max); + const hidden = people.length - visible.length; + + return ( + + {visible.map((p) => ( + + ))} + {hidden > 0 && ( + + +{hidden} + + )} + + ); +}; diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx index b0110be..a5992e3 100644 --- a/frontend/src/components/UserAvatar.tsx +++ b/frontend/src/components/UserAvatar.tsx @@ -8,12 +8,14 @@ export interface UserAvatarProps { } | null; name?: string; avatarUrl?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; className?: string; alt?: string; + showTitle?: boolean; // native name tooltip on hover } const SIZE_MAP = { + xxs: 'w-4 h-4 text-[7px]', xs: 'w-5 h-5 text-[10px]', sm: 'w-7 h-7 text-xs', md: 'w-9 h-9 text-sm', @@ -68,6 +70,7 @@ export const UserAvatar: React.FC = ({ size = 'md', className = '', alt, + showTitle = true, }) => { const [imageError, setImageError] = useState(false); @@ -94,7 +97,7 @@ export const UserAvatar: React.FC = ({ return (
{hasImage ? ( = ({ [engineState?.presence, currentUser?.id] ); + // Presence only carries names; profile pictures come from the user directory, loaded once + // the first time someone else shows up (avatars can be data URLs, too big for every sync) + const [avatarsById, setAvatarsById] = useState>(new Map()); + const avatarsRequestedRef = useRef(false); + useEffect(() => { + if (otherPeople.length === 0 || avatarsRequestedRef.current) return; + avatarsRequestedRef.current = true; + api.getUsersDirectory() + .then((users) => { + const m = new Map(); + for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl); + setAvatarsById(m); + }) + .catch(() => {}); + }, [otherPeople.length]); + // Short hint at the bottom of the editor (blocked actions, skipped undo) const [notice, setNotice] = useState(null); const noticeTimerRef = useRef(null); @@ -816,15 +833,7 @@ export const DocumentEditor: React.FC = ({ )} {/* Who else has this note open */} - {otherPeople.length > 0 && ( - - - {otherPeople.map((p) => p.name).join(', ')} - - )} + {/* Sharing Section */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index fb98dae..2dd001e 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -62,6 +62,7 @@ export interface OutlinerBlockProps { onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onFocusBlock?: (blockId: string) => void; + onBlurBlock?: (blockId: string) => void; onOpenTask?: (taskId: string) => void; onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; @@ -109,6 +110,7 @@ export const OutlinerBlock: React.FC = ({ onIndentBlock, onNavigateFocus, onFocusBlock, + onBlurBlock, onOpenTask, onTagClick, onWikiLinkClick, @@ -409,6 +411,7 @@ export const OutlinerBlock: React.FC = ({ const handleBlur = () => { isFocusedRef.current = false; setIsLocallyFocused(false); + onBlurBlock?.(block.id); if (isPastingRef.current) { isPastingRef.current = false; return; @@ -668,16 +671,6 @@ export const OutlinerBlock: React.FC = ({ } }} > - {/* Indentation Guide Line */} - {visualLevel > 0 && - Array.from({ length: visualLevel }).map((_, lvlIdx) => ( -
- ))} - {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
{hasChildren ? ( diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index b34e894..57b4ff5 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef(({ const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); + // Leaving a block (click into empty space) must drop its focus highlight; a focus move to + // another block has already set the new id, so only clear if it's still this one + const handleBlurBlock = useCallback((blockId: string) => { + setFocusedBlockId((cur) => (cur === blockId ? null : cur)); + }, []); + const textareaMapRef = useRef>(new Map()); const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null); @@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef(({ onIndentBlock={handleIndentBlock} onNavigateFocus={handleNavigateFocus} onFocusBlock={setFocusedBlockId} + onBlurBlock={handleBlurBlock} onOpenTask={onOpenTask} onTagClick={onTagClick} onWikiLinkClick={onWikiLinkClick} diff --git a/frontend/src/features/workspace/PresenceAvatars.tsx b/frontend/src/features/workspace/PresenceAvatars.tsx new file mode 100644 index 0000000..d1206ca --- /dev/null +++ b/frontend/src/features/workspace/PresenceAvatars.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { UserAvatar } from '../../components/UserAvatar'; +import { AvatarStack } from '../../components/AvatarStack'; +import { Presence } from '../../lib/docSync/transport'; + +const MAX_VISIBLE = 3; + +interface PresenceAvatarsProps { + people: Presence[]; + avatars: Map; // userId -> avatarUrl +} + +// Who else has the note open: the shared AvatarStack plus the full list on hover / keyboard focus +export const PresenceAvatars: React.FC = ({ people, avatars }) => { + if (people.length === 0) return null; + return ( +
p.name).join(', ')}`} + > + ({ id: p.userId, name: p.name, avatarUrl: avatars.get(p.userId) }))} + max={MAX_VISIBLE} + /> + +
+
Gerade online
+ {people.map((p) => ( +
+ + + + + {p.name} +
+ ))} +
+
+ ); +};