feat(annotator): support vertical layout for annotation editor (#2882)
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user