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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 20:26:55 +01:00
1 parent 3540b4b2ae
commit 1bd38f4d50
6 files changed
+134 -21

No files matched your search

+60
View File
@@ -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<AvatarStackProps> = ({
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 (
<span className={`inline-flex items-center -space-x-1.5 shrink-0 ${className}`} title={title}>
{visible.map((p) => (
<UserAvatar
key={p.id}
name={p.name}
avatarUrl={p.avatarUrl || ''}
size={size}
showTitle={false}
className={`ring-2 ${ringClass}`}
/>
))}
{hidden > 0 && (
<span
className={`relative inline-flex items-center justify-center rounded-full font-bold leading-none bg-muted text-panel ring-2 ${ringClass} ${OVERFLOW_SIZE[size]}`}
>
+{hidden}
</span>
)}
</span>
);
};
+5 -2
View File
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
} | null; } | null;
name?: string; name?: string;
avatarUrl?: string; avatarUrl?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string; className?: string;
alt?: string; alt?: string;
showTitle?: boolean; // native name tooltip on hover
} }
const SIZE_MAP = { const SIZE_MAP = {
xxs: 'w-4 h-4 text-[7px]',
xs: 'w-5 h-5 text-[10px]', xs: 'w-5 h-5 text-[10px]',
sm: 'w-7 h-7 text-xs', sm: 'w-7 h-7 text-xs',
md: 'w-9 h-9 text-sm', md: 'w-9 h-9 text-sm',
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
size = 'md', size = 'md',
className = '', className = '',
alt, alt,
showTitle = true,
}) => { }) => {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
return ( return (
<div <div
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`} className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
title={effectiveName || alt} title={showTitle ? effectiveName || alt : undefined}
> >
{hasImage ? ( {hasImage ? (
<img <img
@@ -22,6 +22,7 @@ import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
@@ -416,6 +417,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[engineState?.presence, currentUser?.id] [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<Map<string, string>>(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<string, string>();
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) // Short hint at the bottom of the editor (blocked actions, skipped undo)
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const noticeTimerRef = useRef<number | null>(null); const noticeTimerRef = useRef<number | null>(null);
@@ -816,15 +833,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
)} )}
{/* Who else has this note open */} {/* Who else has this note open */}
{otherPeople.length > 0 && ( <PresenceAvatars people={otherPeople} avatars={avatarsById} />
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title="Hat diese Notiz gerade geöffnet"
>
<Users className="w-3 h-3 text-accent" />
{otherPeople.map((p) => p.name).join(', ')}
</span>
)}
{/* Sharing Section */} {/* Sharing Section */}
{doc && (() => { {doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
@@ -62,6 +62,7 @@ export interface OutlinerBlockProps {
onIndentBlock: (blockId: string, delta: 1 | -1) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
onFocusBlock?: (blockId: string) => void; onFocusBlock?: (blockId: string) => void;
onBlurBlock?: (blockId: string) => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void; onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void; onWikiLinkClick?: (pageTitle: string) => void;
@@ -109,6 +110,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlock, onIndentBlock,
onNavigateFocus, onNavigateFocus,
onFocusBlock, onFocusBlock,
onBlurBlock,
onOpenTask, onOpenTask,
onTagClick, onTagClick,
onWikiLinkClick, onWikiLinkClick,
@@ -409,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleBlur = () => { const handleBlur = () => {
isFocusedRef.current = false; isFocusedRef.current = false;
setIsLocallyFocused(false); setIsLocallyFocused(false);
onBlurBlock?.(block.id);
if (isPastingRef.current) { if (isPastingRef.current) {
isPastingRef.current = false; isPastingRef.current = false;
return; return;
@@ -668,16 +671,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
}} }}
> >
{/* Indentation Guide Line */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
style={{ left: `${lvlIdx * 22 + 12}px` }}
/>
))}
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */} {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0"> <div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
{hasChildren ? ( {hasChildren ? (
@@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(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<Map<string, HTMLTextAreaElement>>(new Map()); const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null); const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
@@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onIndentBlock={handleIndentBlock} onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus} onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId} onFocusBlock={setFocusedBlockId}
onBlurBlock={handleBlurBlock}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
onTagClick={onTagClick} onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick} onWikiLinkClick={onWikiLinkClick}
@@ -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<string, string>; // userId -> avatarUrl
}
// Who else has the note open: the shared AvatarStack plus the full list on hover / keyboard focus
export const PresenceAvatars: React.FC<PresenceAvatarsProps> = ({ people, avatars }) => {
if (people.length === 0) return null;
return (
<div
className="group/presence relative flex items-center outline-none"
tabIndex={0}
aria-label={`Gerade online: ${people.map((p) => p.name).join(', ')}`}
>
<AvatarStack
people={people.map((p) => ({ id: p.userId, name: p.name, avatarUrl: avatars.get(p.userId) }))}
max={MAX_VISIBLE}
/>
<div className="invisible opacity-0 group-hover/presence:visible group-hover/presence:opacity-100 group-focus/presence:visible group-focus/presence:opacity-100 transition-opacity absolute right-0 top-full mt-1.5 z-30 min-w-[11rem] max-w-[16rem] p-1.5 rounded-card bg-panel border border-border shadow-lg">
<div className="px-1.5 pb-1 text-[10px] uppercase tracking-wide text-faint">Gerade online</div>
{people.map((p) => (
<div key={p.userId} className="flex items-center gap-2 px-1.5 py-1 text-xs text-main">
<span className="relative">
<UserAvatar name={p.name} avatarUrl={avatars.get(p.userId) || ''} size="xs" />
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-status-ok ring-2 ring-[var(--bg-panel)]" />
</span>
<span className="truncate">{p.name}</span>
</div>
))}
</div>
</div>
);
};