feat(annotator): support vertical layout for annotation editor (#2882)

This commit is contained in:
Huang Xin
2026-01-07 16:18:57 +01:00
committed by GitHub
parent 9614c62360
commit 2ff10f781f
33 changed files with 137 additions and 57 deletions
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import dayjs from 'dayjs';
import React, { useMemo } from 'react';
import { BookNote } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
@@ -11,6 +12,7 @@ interface AnnotationNotesProps {
bookKey: string;
isVertical: boolean;
notes: BookNote[];
toolsVisible: boolean;
triangleDir: 'up' | 'down' | 'left' | 'right';
popupWidth: number;
popupHeight: number;
@@ -21,6 +23,7 @@ const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
bookKey,
isVertical,
notes,
toolsVisible,
triangleDir,
popupWidth,
popupHeight,
@@ -31,7 +34,7 @@ const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
const { setHoveredBookKey } = useReaderStore();
const { setSideBarVisible } = useSidebarStore();
const config = getConfig(bookKey);
const maxSize = useResponsiveSize(150);
const maxSize = useResponsiveSize(250);
const sortedNotes = useMemo(() => {
return [...notes].sort((a, b) => b.updatedAt - a.updatedAt);
@@ -59,15 +62,15 @@ const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
style={{
...(isVertical
? {
right: `${triangleDir === 'left' ? `${popupWidth + 16}px` : undefined}`,
left: `${triangleDir === 'right' ? `${popupWidth + 16}px` : undefined}`,
right: triangleDir === 'left' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
left: triangleDir === 'right' ? `${toolsVisible ? popupWidth + 16 : 0}px` : undefined,
height: `${popupHeight}px`,
maxWidth: `${maxSize}px`,
overflowX: 'auto',
}
: {
top: triangleDir === 'up' ? undefined : `${popupHeight + 16}px`,
bottom: triangleDir === 'up' ? `${popupHeight + 16}px` : undefined,
top: triangleDir === 'down' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
bottom: triangleDir === 'up' ? `${toolsVisible ? popupHeight + 16 : 0}px` : undefined,
width: `${popupWidth}px`,
maxHeight: `${maxSize}px`,
overflowY: 'auto',
@@ -76,7 +79,7 @@ const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
}}
>
<div
className={clsx('flex gap-4', isVertical ? 'h-full flex-row' : 'flex-col')}
className={clsx('flex gap-4', isVertical ? 'h-full flex-row' : 'w-full flex-col')}
style={
isVertical
? {
@@ -122,7 +125,12 @@ const AnnotationNotes: React.FC<AnnotationNotesProps> = ({
: {}
}
>
{note.note}
<div className={clsx('flex flex-col justify-between gap-2')}>
{note.note}
<span className='text-sm text-white/50 sm:text-xs'>
{dayjs(note.createdAt).fromNow()}
</span>
</div>
</div>
)}
</div>
@@ -54,7 +54,10 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
minHeight={isVertical ? popupWidth : popupHeight}
position={position}
trianglePosition={trianglePosition}
className='selection-popup bg-gray-600 text-white'
className={clsx(
'selection-popup bg-gray-600 text-white',
notes.length > 0 && 'bg-transparent',
)}
triangleClassName='text-gray-600'
onDismiss={onDismiss}
>
@@ -63,6 +66,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
className={clsx(
'selection-buttons flex h-full w-full items-center justify-between p-2',
isVertical ? 'flex-col overflow-y-auto' : 'flex-row overflow-x-auto',
notes.length > 0 && 'hidden',
)}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
@@ -85,6 +89,7 @@ const AnnotationPopup: React.FC<AnnotationPopupProps> = ({
bookKey={bookKey}
isVertical={isVertical}
notes={notes}
toolsVisible={false}
triangleDir={trianglePosition.dir!}
popupWidth={isVertical ? popupHeight : popupWidth}
popupHeight={isVertical ? popupWidth : popupHeight}
@@ -4,11 +4,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BookNote, HighlightColor } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
interface HandleProps {
position: Point;
isVertical: boolean;
type: 'start' | 'end';
color: string;
onDragStart: () => void;
@@ -18,6 +20,7 @@ interface HandleProps {
const Handle: React.FC<HandleProps> = ({
position,
isVertical,
type,
color,
onDragStart,
@@ -25,6 +28,9 @@ const Handle: React.FC<HandleProps> = ({
onDragEnd,
}) => {
const isDragging = useRef(false);
const size = useResponsiveSize(24);
const circleRadius = useResponsiveSize(8);
const stemHeight = useResponsiveSize(12);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
@@ -59,16 +65,22 @@ const Handle: React.FC<HandleProps> = ({
[onDragEnd],
);
const size = 24;
const circleRadius = 8;
const stemHeight = 8;
return (
<div
className='pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing'
style={{
left: position.x - size / 2,
top: type === 'start' ? position.y - size : position.y - size / 2,
left: isVertical
? type === 'start'
? position.x - size / 2 + stemHeight / 4
: position.x - size / 2
: position.x - size / 2,
top: isVertical
? type === 'start'
? position.y - size + stemHeight / 2
: position.y - size / 2 - stemHeight / 2
: type === 'start'
? position.y - size
: position.y - size / 2 - stemHeight / 8,
width: size,
height: size + stemHeight,
}}
@@ -82,7 +94,15 @@ const Handle: React.FC<HandleProps> = ({
height={size + stemHeight}
viewBox={`0 0 ${size} ${size + stemHeight}`}
className={clsx(type === 'start' && 'rotate-180')}
style={{ transform: type === 'start' ? 'rotate(180deg)' : undefined }}
style={{
transform: isVertical
? type === 'start'
? 'rotate(270deg)'
: 'rotate(90deg)'
: type === 'start'
? 'rotate(180deg)'
: undefined,
}}
>
{/* Stem/line */}
<line
@@ -103,6 +123,7 @@ const Handle: React.FC<HandleProps> = ({
interface AnnotationRangeEditorProps {
bookKey: string;
isVertical: boolean;
annotation: BookNote;
selection: TextSelection;
handleColor: HighlightColor;
@@ -113,6 +134,7 @@ interface AnnotationRangeEditorProps {
const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
bookKey,
isVertical,
annotation,
selection,
handleColor,
@@ -137,7 +159,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
initializedRef.current = true;
const range = selection.range;
const positions = getHandlePositionsFromRange(range);
const positions = getHandlePositionsFromRange(range, isVertical);
if (positions) {
setTimeout(() => {
setCurrentStart(positions.start);
@@ -146,7 +168,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
startRef.current = positions.start;
endRef.current = positions.end;
}
}, [annotation, selection, getHandlePositionsFromRange]);
}, [annotation, selection, isVertical, getHandlePositionsFromRange]);
useEffect(() => {
if (!handlePositions || draggingRef.current) return;
@@ -172,24 +194,24 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
(point: Point) => {
setCurrentStart(point);
startRef.current = point;
handleAnnotationRangeChange(point, endRef.current, true);
handleAnnotationRangeChange(point, endRef.current, isVertical, true);
},
[handleAnnotationRangeChange],
[isVertical, handleAnnotationRangeChange],
);
const handleEndDrag = useCallback(
(point: Point) => {
setCurrentEnd(point);
endRef.current = point;
handleAnnotationRangeChange(startRef.current, point, true);
handleAnnotationRangeChange(startRef.current, point, isVertical, true);
},
[handleAnnotationRangeChange],
[isVertical, handleAnnotationRangeChange],
);
const handleDragEnd = useCallback(() => {
draggingRef.current = null;
handleAnnotationRangeChange(startRef.current, endRef.current, false);
}, [handleAnnotationRangeChange]);
handleAnnotationRangeChange(startRef.current, endRef.current, isVertical, false);
}, [isVertical, handleAnnotationRangeChange]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
@@ -199,6 +221,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
<div className='pointer-events-none fixed inset-0 z-40'>
<Handle
position={currentStart}
isVertical={isVertical}
type='start'
color={handleColorHex}
onDragStart={handleStartDragStart}
@@ -207,6 +230,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
/>
<Handle
position={currentEnd}
isVertical={isVertical}
type='end'
color={handleColorHex}
onDragStart={handleEndDragStart}
@@ -939,6 +939,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
{editingAnnotation && editingAnnotation.color && selection && (
<AnnotationRangeEditor
bookKey={bookKey}
isVertical={viewSettings.vertical}
annotation={editingAnnotation}
selection={selection}
handleColor={selectedColor}
@@ -1,10 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import { MdCheck } from 'react-icons/md';
import { AnnotationToolType } from '@/types/annotator';
import { useTranslation } from '@/hooks/useTranslation';
import { annotationToolQuickActions } from './AnnotationTools';
import { eventDispatcher } from '@/utils/event';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
@@ -23,6 +23,20 @@ const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
const handleActionClick = (action: AnnotationToolType) => {
onActionSelect(action);
if (selectedAction === action) {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _('Quick action disabled'),
});
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
message: _(annotationToolQuickActions.find((btn) => btn.type === action)?.tooltip || ''),
});
}
setIsDropdownOpen?.(false);
};
return (
@@ -41,7 +55,8 @@ const QuickActionMenu: React.FC<QuickActionMenuProps> = ({
key={button.type}
label={_(button.label)}
tooltip={_(button.tooltip)}
Icon={selectedAction === button.type ? MdCheck : button.Icon}
buttonClass={selectedAction === button.type ? 'bg-base-300/85' : ''}
Icon={button.Icon}
onClick={() => handleActionClick(button.type)}
/>
))}