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
@@ -874,5 +874,6 @@
"Annotations": "التعليقات التوضيحية",
"Show Results": "عرض النتائج",
"Clear search": "مسح البحث",
"Clear search history": "مسح سجل البحث"
"Clear search history": "مسح سجل البحث",
"Quick action disabled": "الإجراء السريع معطل"
}
@@ -834,5 +834,6 @@
"Annotations": "টীকা",
"Show Results": "ফলাফল দেখুন",
"Clear search": "অনুসন্ধান সাফ করুন",
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন"
"Clear search history": "অনুসন্ধান ইতিহাস সাফ করুন",
"Quick action disabled": "দ্রুত কার্যক্রম অক্ষম"
}
@@ -824,5 +824,6 @@
"Annotations": "མཆན།",
"Show Results": "འབྲས་བུ་སྟོན།",
"Clear search": "འཚོལ་བཤེར་གཙང་སེལ།",
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།"
"Clear search history": "འཚོལ་བཤེར་ལོ་རྒྱུས་གཙང་སེལ།",
"Quick action disabled": "མྱུར་མགྱོགས་བྱ་འགུལ་བཀག་འགོག"
}
@@ -834,5 +834,6 @@
"Annotations": "Anmerkungen",
"Show Results": "Ergebnisse anzeigen",
"Clear search": "Suche löschen",
"Clear search history": "Suchverlauf löschen"
"Clear search history": "Suchverlauf löschen",
"Quick action disabled": "Schnellaktion deaktiviert"
}
@@ -834,5 +834,6 @@
"Annotations": "Σχολιασμοί",
"Show Results": "Εμφάνιση αποτελεσμάτων",
"Clear search": "Εκκαθάριση αναζήτησης",
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης"
"Clear search history": "Εκκαθάριση ιστορικού αναζήτησης",
"Quick action disabled": "Η γρήγορη ενέργεια απενεργοποιήθηκε"
}
@@ -844,5 +844,6 @@
"Annotations": "Anotaciones",
"Show Results": "Mostrar resultados",
"Clear search": "Borrar búsqueda",
"Clear search history": "Borrar historial de búsqueda"
"Clear search history": "Borrar historial de búsqueda",
"Quick action disabled": "Acción rápida desactivada"
}
@@ -834,5 +834,6 @@
"Annotations": "یادداشت‌ها",
"Show Results": "نمایش نتایج",
"Clear search": "پاک کردن جستجو",
"Clear search history": "پاک کردن تاریخچه جستجو"
"Clear search history": "پاک کردن تاریخچه جستجو",
"Quick action disabled": "عملکرد سریع غیرفعال است"
}
@@ -844,5 +844,6 @@
"Annotations": "Annotations",
"Show Results": "Afficher les résultats",
"Clear search": "Effacer la recherche",
"Clear search history": "Effacer l'historique de recherche"
"Clear search history": "Effacer l'historique de recherche",
"Quick action disabled": "Action rapide désactivée"
}
@@ -834,5 +834,6 @@
"Annotations": "टिप्पणियाँ",
"Show Results": "परिणाम दिखाएं",
"Clear search": "खोज साफ़ करें",
"Clear search history": "खोज इतिहास साफ़ करें"
"Clear search history": "खोज इतिहास साफ़ करें",
"Quick action disabled": "त्वरित क्रिया अक्षम"
}
@@ -824,5 +824,6 @@
"Annotations": "Anotasi",
"Show Results": "Tampilkan Hasil",
"Clear search": "Hapus pencarian",
"Clear search history": "Hapus riwayat pencarian"
"Clear search history": "Hapus riwayat pencarian",
"Quick action disabled": "Tindakan cepat dinonaktifkan"
}
@@ -844,5 +844,6 @@
"Annotations": "Annotazioni",
"Show Results": "Mostra risultati",
"Clear search": "Cancella ricerca",
"Clear search history": "Cancella cronologia ricerche"
"Clear search history": "Cancella cronologia ricerche",
"Quick action disabled": "Azione rapida disattivata"
}
@@ -824,5 +824,6 @@
"Annotations": "注釈",
"Show Results": "結果を表示",
"Clear search": "検索をクリア",
"Clear search history": "検索履歴をクリア"
"Clear search history": "検索履歴をクリア",
"Quick action disabled": "クイックアクション無効"
}
@@ -824,5 +824,6 @@
"Annotations": "주석",
"Show Results": "결과 표시",
"Clear search": "검색 지우기",
"Clear search history": "검색 기록 지우기"
"Clear search history": "검색 기록 지우기",
"Quick action disabled": "빠른 작업 비활성화됨"
}
@@ -824,5 +824,6 @@
"Annotations": "Anotasi",
"Show Results": "Tunjukkan Hasil",
"Clear search": "Kosongkan carian",
"Clear search history": "Kosongkan sejarah carian"
"Clear search history": "Kosongkan sejarah carian",
"Quick action disabled": "Tindakan pantas dilumpuhkan"
}
@@ -834,5 +834,6 @@
"Annotations": "Aantekeningen",
"Show Results": "Resultaten tonen",
"Clear search": "Zoekopdracht wissen",
"Clear search history": "Zoekgeschiedenis wissen"
"Clear search history": "Zoekgeschiedenis wissen",
"Quick action disabled": "Snelle actie uitgeschakeld"
}
@@ -854,5 +854,6 @@
"Annotations": "Adnotacje",
"Show Results": "Pokaż wyniki",
"Clear search": "Wyczyść wyszukiwanie",
"Clear search history": "Wyczyść historię wyszukiwania"
"Clear search history": "Wyczyść historię wyszukiwania",
"Quick action disabled": "Szybka akcja wyłączona"
}
@@ -844,5 +844,6 @@
"Annotations": "Anotações",
"Show Results": "Mostrar resultados",
"Clear search": "Limpar pesquisa",
"Clear search history": "Limpar histórico de pesquisa"
"Clear search history": "Limpar histórico de pesquisa",
"Quick action disabled": "Ação rápida desativada"
}
@@ -854,5 +854,6 @@
"Annotations": "Аннотации",
"Show Results": "Показать результаты",
"Clear search": "Очистить поиск",
"Clear search history": "Очистить историю поиска"
"Clear search history": "Очистить историю поиска",
"Quick action disabled": "Быстрое действие отключено"
}
@@ -834,5 +834,6 @@
"Annotations": "අනුසටහන්",
"Show Results": "ප්‍රතිඵල පෙන්වන්න",
"Clear search": "සෙවුම හිස් කරන්න",
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න"
"Clear search history": "සෙවුම් ඉතිහාසය හිස් කරන්න",
"Quick action disabled": "ඉක්මන් ක්‍රියාව අක්‍රීයයි"
}
@@ -834,5 +834,6 @@
"Annotations": "Anteckningar",
"Show Results": "Visa resultat",
"Clear search": "Rensa sökning",
"Clear search history": "Rensa sökhistorik"
"Clear search history": "Rensa sökhistorik",
"Quick action disabled": "Snabbåtgärd inaktiverad"
}
@@ -834,5 +834,6 @@
"Annotations": "சிறுகுறிப்புகள்",
"Show Results": "முடிவுகளைக் காட்டு",
"Clear search": "தேடலை அழி",
"Clear search history": "தேடல் வரலாற்றை அழி"
"Clear search history": "தேடல் வரலாற்றை அழி",
"Quick action disabled": "விரைவு செயல் முடக்கப்பட்டது"
}
@@ -824,5 +824,6 @@
"Annotations": "คำอธิบายประกอบ",
"Show Results": "แสดงผลลัพธ์",
"Clear search": "ล้างการค้นหา",
"Clear search history": "ล้างประวัติการค้นหา"
"Clear search history": "ล้างประวัติการค้นหา",
"Quick action disabled": "ปิดใช้งานการดำเนินการด่วน"
}
@@ -834,5 +834,6 @@
"Annotations": "Açıklamalar",
"Show Results": "Sonuçları Göster",
"Clear search": "Aramayı temizle",
"Clear search history": "Arama geçmişini temizle"
"Clear search history": "Arama geçmişini temizle",
"Quick action disabled": "Hızlı eylem devre dışı"
}
@@ -854,5 +854,6 @@
"Annotations": "Анотації",
"Show Results": "Показати результати",
"Clear search": "Очистити пошук",
"Clear search history": "Очистити історію пошуку"
"Clear search history": "Очистити історію пошуку",
"Quick action disabled": "Швидку дію вимкнено"
}
@@ -824,5 +824,6 @@
"Annotations": "Chú thích",
"Show Results": "Hiển thị kết quả",
"Clear search": "Xóa tìm kiếm",
"Clear search history": "Xóa lịch sử tìm kiếm"
"Clear search history": "Xóa lịch sử tìm kiếm",
"Quick action disabled": "Đã tắt thao tác nhanh"
}
@@ -824,5 +824,6 @@
"Annotations": "注释",
"Show Results": "显示结果",
"Clear search": "清除搜索",
"Clear search history": "清除搜索历史"
"Clear search history": "清除搜索历史",
"Quick action disabled": "快捷操作已禁用"
}
@@ -824,5 +824,6 @@
"Annotations": "註解",
"Show Results": "顯示結果",
"Clear search": "清除搜尋",
"Clear search history": "清除搜尋歷史"
"Clear search history": "清除搜尋歷史",
"Quick action disabled": "快捷操作已停用"
}
@@ -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)}
/>
))}
@@ -34,7 +34,7 @@ export const useAnnotationEditor = ({
const [handlePositions, setHandlePositions] = useState<HandlePositions | null>(null);
const getHandlePositionsFromRange = useCallback(
(range: Range): HandlePositions | null => {
(range: Range, isVertical: boolean): HandlePositions | null => {
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return null;
@@ -48,11 +48,11 @@ export const useAnnotationEditor = ({
return {
start: {
x: frameRect.left + firstRect.left,
x: frameRect.left + (isVertical ? firstRect.right : firstRect.left),
y: frameRect.top + firstRect.top,
},
end: {
x: frameRect.left + lastRect.right,
x: frameRect.left + (isVertical ? lastRect.left : lastRect.right),
y: frameRect.top + lastRect.bottom,
},
};
@@ -61,7 +61,7 @@ export const useAnnotationEditor = ({
);
const handleAnnotationRangeChange = useCallback(
async (startPoint: Point, endPoint: Point, isDragging: boolean) => {
async (startPoint: Point, endPoint: Point, isVertical: boolean, isDragging: boolean) => {
if (!editingAnnotationRef.current || !view) return;
const contents = view.renderer.getContents();
@@ -132,7 +132,7 @@ export const useAnnotationEditor = ({
return;
}
const newPositions = getHandlePositionsFromRange(newRange);
const newPositions = getHandlePositionsFromRange(newRange, isVertical);
if (newPositions) {
setHandlePositions(newPositions);
}