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:
1 parent
3540b4b2ae
commit
1bd38f4d50
6 files changed
+134
-21
No files matched your search
@@ -22,6 +22,7 @@ import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
@@ -416,6 +417,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[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)
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
@@ -816,15 +833,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
{otherPeople.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||
{/* Sharing Section */}
|
||||
{doc && (() => {
|
||||
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;
|
||||
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<OutlinerBlockProps> = ({
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onFocusBlock,
|
||||
onBlurBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
@@ -409,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
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<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 */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{hasChildren ? (
|
||||
|
||||
@@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
const [draggedIndex, setDraggedIndex] = 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 pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
||||
|
||||
@@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onBlurBlock={handleBlurBlock}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user