Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 30 additions & 29 deletions packages/app-expo/src/components/reader/SelectionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { RichTextEditor } from "@/components/ui/RichTextEditor";
import type { SelectionEvent } from "@/hooks/use-reader-bridge";
import { radius, spacing, useColors } from "@/styles/theme";
import type { ThemeColors } from "@/styles/theme";
import { HIGHLIGHT_COLORS, HIGHLIGHT_COLOR_HEX } from "@readany/core/types";
import type { HighlightColor } from "@readany/core/types";
import * as Clipboard from "expo-clipboard";
/**
* SelectionPopover — floating action bar shown when text is selected in the reader.
Expand All @@ -31,15 +33,6 @@ import {
View,
} from "react-native";

const HIGHLIGHT_COLORS = [
{ key: "yellow", hex: "#facc15" },
{ key: "red", hex: "#f87171" },
{ key: "green", hex: "#4ade80" },
{ key: "blue", hex: "#60a5fa" },
{ key: "violet", hex: "#a78bfa" },
{ key: "pink", hex: "#f472b6" },
] as const;

const SCREEN_WIDTH = Dimensions.get("window").width;
const SCREEN_HEIGHT = Dimensions.get("window").height;
const POPOVER_MARGIN = 8;
Expand All @@ -53,15 +46,16 @@ const SELECTION_POPOVER_BELOW_OFFSET = 6;

interface Props {
selection: SelectionEvent;
onHighlight: (color: string) => void;
onHighlight: (color: HighlightColor) => void;
onDismiss: () => void;
onCopy: () => void;
onAIChat: () => void;
onSpeak?: (text: string, cfi: string) => void;
onNote?: (text: string, cfi: string) => void;
onTranslate?: (text: string) => void;
onRemoveHighlight?: () => void;
existingHighlight?: { id: string; color: string; note?: string } | null;
existingHighlight?: { id: string; color: HighlightColor; note?: string } | null;
defaultColor?: HighlightColor;
}

export function SelectionPopover({
Expand All @@ -75,24 +69,31 @@ export function SelectionPopover({
onTranslate,
onRemoveHighlight,
existingHighlight,
defaultColor = "yellow",
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const s = useMemo(() => makeStyles(colors), [colors]);
const [showNoteModal, setShowNoteModal] = useState(false);
const [showColors, setShowColors] = useState(!!existingHighlight);
const [noteContent, setNoteContent] = useState(existingHighlight?.note || "");
const existingHighlightNote = existingHighlight?.note || "";
const hasExistingHighlight = !!existingHighlight;

useEffect(() => {
setNoteContent(existingHighlightNote);
}, [existingHighlightNote]);

useEffect(() => {
setNoteContent(existingHighlight?.note || "");
}, [existingHighlight?.id, existingHighlight?.note, selection.cfi]);
setShowColors(hasExistingHighlight);
}, [hasExistingHighlight]);

const buttonCount =
4 +
(onNote ? 1 : 0) +
(onTranslate ? 1 : 0) +
(onSpeak ? 1 : 0) +
(existingHighlight && onRemoveHighlight ? 1 : 0);
(hasExistingHighlight && onRemoveHighlight ? 1 : 0);
const colorRowHeight = showColors ? 40 : 0;
const popoverHeight = 44 + colorRowHeight + POPOVER_PADDING * 2 + GAP;
const popoverWidth = Math.min(
Expand All @@ -114,18 +115,14 @@ export function SelectionPopover({
const yAbove = selTop - popoverHeight + SELECTION_POPOVER_ABOVE_OFFSET;
const yBelow = selBottom + SELECTION_POPOVER_BELOW_OFFSET;
const aboveValid = yAbove >= SAFE_TOP;
const belowValid =
yBelow + popoverHeight + POPOVER_MARGIN <= SCREEN_HEIGHT - SAFE_BOTTOM;
const belowValid = yBelow + popoverHeight + POPOVER_MARGIN <= SCREEN_HEIGHT - SAFE_BOTTOM;

if (aboveValid) {
y = yAbove;
} else if (belowValid) {
y = yBelow;
} else {
y = Math.max(
SAFE_TOP,
Math.min(yBelow, SCREEN_HEIGHT - popoverHeight - POPOVER_MARGIN),
);
y = Math.max(SAFE_TOP, Math.min(yBelow, SCREEN_HEIGHT - popoverHeight - POPOVER_MARGIN));
}

return { x, y };
Expand Down Expand Up @@ -170,25 +167,29 @@ export function SelectionPopover({
onDismiss();
}, [onRemoveHighlight, onDismiss]);

const toggleColors = useCallback(() => {
setShowColors((prev) => !prev);
}, []);
const handleHighlightPress = useCallback(() => {
if (hasExistingHighlight) {
setShowColors((prev) => !prev);
return;
}
onHighlight(defaultColor);
}, [defaultColor, hasExistingHighlight, onHighlight]);

return (
<View style={[s.overlay]} pointerEvents="box-none">
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={onDismiss} />
<View style={[s.popover, { left: position.x, top: position.y }]}>
{showColors && (
<View style={s.colorRow}>
{HIGHLIGHT_COLORS.map((c) => (
{HIGHLIGHT_COLORS.map((color) => (
<TouchableOpacity
key={c.key}
key={color}
style={[
s.colorDot,
{ backgroundColor: c.hex },
existingHighlight?.color === c.key && s.colorDotActive,
{ backgroundColor: HIGHLIGHT_COLOR_HEX[color] },
(existingHighlight?.color ?? defaultColor) === color && s.colorDotActive,
]}
onPress={() => onHighlight(c.key)}
onPress={() => onHighlight(color)}
/>
))}
</View>
Expand All @@ -197,7 +198,7 @@ export function SelectionPopover({
<View style={s.actionRow}>
<TouchableOpacity
style={[s.iconBtn, showColors && s.iconBtnActive]}
onPress={toggleColors}
onPress={handleHighlightPress}
>
<HighlighterIcon size={18} color={showColors ? colors.primary : colors.foreground} />
</TouchableOpacity>
Expand Down
50 changes: 43 additions & 7 deletions packages/app-expo/src/screens/ReaderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { useReadingSession } from "@readany/core/hooks/use-reading-session";
import { createSelectionNoteMutation } from "@readany/core/reader";
import { getPlatformService } from "@readany/core/services";
import { getCSSFontFace, useFontStore } from "@readany/core/stores";
import type { ReadSettings, TOCItem } from "@readany/core/types";
import type { HighlightColor, ReadSettings, TOCItem } from "@readany/core/types";
import { eventBus } from "@readany/core/utils/event-bus";
import { throttle } from "@readany/core/utils/throttle";
import { Asset } from "expo-asset";
Expand Down Expand Up @@ -977,14 +977,36 @@ export function ReaderScreen({ route, navigation }: Props) {

// Selection popover handlers
const handleHighlight = useCallback(
(color: string) => {
(color: HighlightColor = readSettings.defaultHighlightColor ?? "yellow") => {
if (!selection) return;
updateReadSettings({ defaultHighlightColor: color });

const existingHighlight = highlights.find(
(h) => h.bookId === bookId && h.cfi === selection.cfi,
);

if (existingHighlight) {
updateHighlight(existingHighlight.id, {
color,
updatedAt: Date.now(),
});
bridge.removeAnnotation({ value: existingHighlight.cfi });
bridge.addAnnotation({
value: existingHighlight.cfi,
type: "highlight",
color,
note: existingHighlight.note,
});
setSelection(null);
return;
}

const highlight = {
id: `hl-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
bookId,
cfi: selection.cfi,
text: selection.text,
color: color as any,
color,
chapterTitle: currentChapter,
createdAt: Date.now(),
updatedAt: Date.now(),
Expand All @@ -993,7 +1015,17 @@ export function ReaderScreen({ route, navigation }: Props) {
bridge.addAnnotation({ value: selection.cfi, type: "highlight", color });
setSelection(null);
},
[selection, bookId, currentChapter, addHighlight, bridge],
[
selection,
readSettings.defaultHighlightColor,
updateReadSettings,
highlights,
bookId,
currentChapter,
addHighlight,
updateHighlight,
bridge,
],
);

const handleDismissSelection = useCallback(() => {
Expand Down Expand Up @@ -1371,7 +1403,8 @@ export function ReaderScreen({ route, navigation }: Props) {

const isPanelOpen = showTOC || showSettings || showSearch || showNotebook || showTranslation;
const existingSelectionHighlight = selection
? (highlights.find((highlight) => highlight.cfi === selection.cfi) ?? null)
? (highlights.find((highlight) => highlight.bookId === bookId && highlight.cfi === selection.cfi) ??
null)
: null;
const readerTopMargin = !showSearch
? showTopTitleProgress
Expand Down Expand Up @@ -1570,7 +1603,7 @@ export function ReaderScreen({ route, navigation }: Props) {
note: text,
chapterTitle: currentChapter,
existingHighlight: existingSelectionHighlight,
defaultColor: "yellow",
defaultColor: readSettings.defaultHighlightColor ?? "yellow",
});

if (mutation.kind === "create") {
Expand Down Expand Up @@ -1605,8 +1638,11 @@ export function ReaderScreen({ route, navigation }: Props) {
}
: null
}
defaultColor={readSettings.defaultHighlightColor ?? "yellow"}
onRemoveHighlight={() => {
const existing = highlights.find((h) => h.cfi === selectionPopoverSelection.cfi);
const existing = highlights.find(
(h) => h.bookId === bookId && h.cfi === selectionPopoverSelection.cfi,
);
if (existing) {
removeHighlight(existing.id);
bridge.removeAnnotation({ value: existing.cfi });
Expand Down
1 change: 1 addition & 0 deletions packages/app-expo/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const defaultReadSettings: ReadSettings = {
showTopTitleProgress: true,
showBottomTimeBattery: true,
volumeButtonsPageTurn: false,
defaultHighlightColor: "yellow",
};

const defaultTranslationConfig: TranslationConfig = {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/reader/FoliateViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,7 @@ export const FoliateViewer = forwardRef<FoliateViewerHandle, FoliateViewerProps>
green: "rgba(74, 222, 128, 0.4)", // green-400
blue: "rgba(96, 165, 250, 0.4)", // blue-400
pink: "rgba(236, 72, 153, 0.4)", // pink-400 - ADDED
purple: "rgba(192, 132, 252, 0.4)", // purple-400
violet: "rgba(167, 139, 250, 0.4)", // violet-400
};

Expand Down
62 changes: 51 additions & 11 deletions packages/app/src/components/reader/ReaderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
goToCfi: (cfi) => foliateRef.current?.goToCFI(cfi),
});

// Track which highlights have been rendered (id -> {cfi, note}) to detect changes
const renderedHighlightsRef = useRef<Map<string, { cfi: string; hasNote: boolean }>>(new Map());
// Track which highlights have been rendered (id -> {cfi, note, color}) to detect changes
const renderedHighlightsRef = useRef<
Map<string, { cfi: string; hasNote: boolean; color?: HighlightColor }>
>(new Map());

// Reset rendered highlights tracking when book changes
useEffect(() => {
Expand Down Expand Up @@ -599,15 +601,16 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
}
}

// Add new highlights or update existing ones if note status changed
// Add new highlights or update existing ones if note status or color changed
for (const h of bookHighlights) {
if (!h.cfi) continue;

const existing = renderedHighlightsRef.current.get(h.id);
const hasNote = !!h.note;
const color = h.color || "yellow";

// Check if we need to re-render (new highlight or note status changed)
const needsRender = !existing || existing.hasNote !== hasNote;
// Check if we need to re-render (new highlight, note status changed, or color changed)
const needsRender = !existing || existing.hasNote !== hasNote || existing.color !== color;

if (needsRender) {
// Remove old annotation if exists
Expand All @@ -619,10 +622,10 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
foliateRef.current.addAnnotation({
value: h.cfi,
type: "highlight",
color: h.color || "yellow",
color,
note: h.note, // Pass note for wavy underline + tooltip
});
renderedHighlightsRef.current.set(h.id, { cfi: h.cfi, hasNote });
renderedHighlightsRef.current.set(h.id, { cfi: h.cfi, hasNote, color });
}
}
}, 100);
Expand Down Expand Up @@ -1212,7 +1215,11 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
color: h.color || "yellow",
note: h.note, // Pass note for wavy underline + tooltip
});
renderedHighlightsRef.current.set(h.id, { cfi: h.cfi, hasNote: !!h.note });
renderedHighlightsRef.current.set(h.id, {
cfi: h.cfi,
hasNote: !!h.note,
color: h.color || "yellow",
});
}
}

Expand Down Expand Up @@ -1367,8 +1374,29 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {

// --- Selection actions ---
const handleHighlight = useCallback(
(color: HighlightColor = "yellow") => {
(color: HighlightColor = viewSettings.defaultHighlightColor ?? "yellow") => {
if (selection && selection.cfi) {
updateReadSettings({ defaultHighlightColor: color });
const existingHighlight = selection.highlightId
? highlights.find((h) => h.id === selection.highlightId)
: highlights.find((h) => h.bookId === bookId && h.cfi === selection.cfi);

if (existingHighlight) {
useAnnotationStore.getState().updateHighlight(existingHighlight.id, {
color,
updatedAt: Date.now(),
});
foliateRef.current?.deleteAnnotation({ value: existingHighlight.cfi });
foliateRef.current?.addAnnotation({
value: existingHighlight.cfi,
type: "highlight",
color,
note: existingHighlight.note,
});
setSelection(null);
return;
}

const highlightId = crypto.randomUUID();

// Add to store (for persistence)
Expand All @@ -1391,11 +1419,22 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
});

// Track as rendered
renderedHighlightsRef.current.set(highlightId, { cfi: selection.cfi, hasNote: false });
renderedHighlightsRef.current.set(highlightId, {
cfi: selection.cfi,
hasNote: false,
color,
});
}
setSelection(null);
},
[selection, bookId, readerTab?.chapterTitle],
[
selection,
bookId,
readerTab?.chapterTitle,
highlights,
updateReadSettings,
viewSettings.defaultHighlightColor,
],
);

// Handle note button - open notebook panel with pending note
Expand Down Expand Up @@ -2728,6 +2767,7 @@ export function ReaderView({ bookId, tabId }: ReaderViewProps) {
selectedText={selection.text}
annotated={selection.annotated}
currentColor={selection.color as HighlightColor | undefined}
defaultColor={viewSettings.defaultHighlightColor ?? "yellow"}
isPdf={bookFormat === "PDF"}
onHighlight={handleHighlight}
onRemoveHighlight={handleRemoveHighlight}
Expand Down
Loading