Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
38f8580
feat: add synchronized reading progress for bookmarks
esimkowitz Dec 25, 2025
a546c28
it works
esimkowitz Dec 27, 2025
587c5e9
align mobile functions
esimkowitz Dec 27, 2025
0abad35
fmt
esimkowitz Dec 27, 2025
e41d15e
use generator for webview js fns, remove empty index.ts
esimkowitz Dec 27, 2025
63d9639
revert comments
esimkowitz Dec 27, 2025
e65c6d2
move shared fns into core
esimkowitz Dec 27, 2025
aced52c
address pr review
esimkowitz Dec 27, 2025
9e7c901
watch core too
esimkowitz Dec 27, 2025
cc0945d
address some pr comments
esimkowitz Dec 27, 2025
1ca773c
fix race condition and jump when scrolling
esimkowitz Dec 27, 2025
b365f51
fix dead code and error handling, simplify some duplicate logic
esimkowitz Dec 27, 2025
071c694
move reading progress to separate table
esimkowitz Dec 27, 2025
838d35d
remove arbitrary >100 char threshold for updates to progress
esimkowitz Dec 27, 2025
e47efc6
only allow reading progress for link bookmarks, error on others
esimkowitz Dec 27, 2025
0a45927
add tests
esimkowitz Dec 27, 2025
d58f920
add tests for shared access reading progress
esimkowitz Dec 27, 2025
87095b6
simplify reading-progress hooks, visibility checks, remove char thres…
esimkowitz Dec 27, 2025
39d89a2
remove unnecessary restoration lock
esimkowitz Dec 27, 2025
7d42d06
remove unnecessary exports from reading-progress-core
esimkowitz Dec 27, 2025
1ed82e8
move position restoration to onload, more deterministic
esimkowitz Dec 27, 2025
a50f866
cleanup effects
esimkowitz Dec 27, 2025
ff45cc0
Update packages/shared-react/hooks/reading-progress.ts
esimkowitz Dec 27, 2025
835fa1e
Merge branch 'main' into evan/reading-progress
esimkowitz Dec 27, 2025
33eea16
Merge branch 'evan/reading-progress' of github.com:esimkowitz/karakee…
esimkowitz Dec 27, 2025
c865564
fix function signatures in generated js comment
esimkowitz Dec 28, 2025
b4cbc3e
remove verbose comments
esimkowitz Dec 28, 2025
fb6f834
Merge branch 'main' into evan/reading-progress
esimkowitz Dec 31, 2025
f61b0ff
Merge branch 'main' into evan/reading-progress
esimkowitz Jan 2, 2026
2afe819
add percentage progress fallback
esimkowitz Jan 2, 2026
79600df
address comments
esimkowitz Jan 2, 2026
d690d08
Merge branch 'main' into evan/reading-progress
esimkowitz Jan 20, 2026
a80151c
some code dedupe and simplification
esimkowitz Jan 20, 2026
6f15640
missed one
esimkowitz Jan 21, 2026
c439131
Remove lastKnownPositionRef
esimkowitz Jan 26, 2026
f83edfe
remove unnecessary reading-progress-core test
esimkowitz Jan 26, 2026
b3d6000
update savePosition comment
esimkowitz Jan 26, 2026
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
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pnpm preflight
pnpm exec sherif
pnpm run --filter @karakeep/open-api check
pnpm run --filter @karakeep/shared check:webview-js
155 changes: 152 additions & 3 deletions apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Pressable, View } from "react-native";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Pressable, View } from "react-native";
import ImageView from "react-native-image-viewing";
import WebView from "react-native-webview";
import WebView, { WebViewMessageEvent } from "react-native-webview";
import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes";
import { Text } from "@/components/ui/Text";
import { useAssetUrl } from "@/lib/hooks";
Expand All @@ -10,6 +10,11 @@ import { api } from "@/lib/trpc";
import { useColorScheme } from "@/lib/useColorScheme";

import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import {
PERIODIC_SAVE_INTERVAL_MS,
SCROLL_THROTTLE_MS,
} from "@karakeep/shared/utils/reading-progress-core";
import { READING_PROGRESS_WEBVIEW_JS } from "@karakeep/shared/utils/reading-progress-webview.generated";

import FullPageError from "../FullPageError";
import FullPageSpinner from "../ui/FullPageSpinner";
Expand All @@ -34,6 +39,73 @@ export function BookmarkLinkBrowserPreview({
);
}

/**
* Builds the reading progress injection script for WebView.
*
* Uses shared reading progress functions from @karakeep/shared to ensure
* consistent behavior between web and mobile. The shared code includes
* whitespace normalization and anchor text support for reliable position tracking.
*
* @param initialOffset - The saved reading position offset to restore
* @param initialAnchor - The saved anchor text for position verification
* @returns JavaScript string to inject into WebView
*/
function buildReadingProgressScript(
initialOffset: number,
initialAnchor: string | null,
): string {
return `
(function() {
// Core reading progress functions from @karakeep/shared (bundled IIFE)
// These are shared with the web implementation for consistency
${READING_PROGRESS_WEBVIEW_JS}

// Extract functions from the IIFE global
var getReadingPosition = __readingProgress.getReadingPosition;
var scrollToReadingPosition = __readingProgress.scrollToReadingPosition;

// Report current position to React Native
function reportProgress() {
var position = getReadingPosition(document.body);
if (position && position.offset > 0) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'SCROLL_PROGRESS',
offset: position.offset,
anchor: position.anchor,
percent: position.percent
}));
}
}

// Restore position immediately (no setTimeout needed for inline HTML -
// DOM is ready when injectedJavaScript runs)
var initialOffset = ${initialOffset};
var initialAnchor = ${JSON.stringify(initialAnchor)};
if (initialOffset && initialOffset > 0) {
scrollToReadingPosition(document.body, initialOffset, 'instant', initialAnchor);
}
// Show content after scroll restoration (or immediately if no scroll needed)
document.body.style.opacity = '1';

// Report on scroll (throttled to prevent jank from expensive DOM operations)
var lastScrollTime = 0;
window.addEventListener('scroll', function() {
var now = Date.now();
if (now - lastScrollTime < ${SCROLL_THROTTLE_MS}) {
return;
}
lastScrollTime = now;
reportProgress();
});

// Also report periodically as backup
setInterval(reportProgress, ${PERIODIC_SAVE_INTERVAL_MS});

true; // Required for injectedJavaScript
})();
`;
}

export function BookmarkLinkPdfPreview({ bookmark }: { bookmark: ZBookmark }) {
if (bookmark.content.type !== BookmarkTypes.LINK) {
throw new Error("Wrong content type rendered");
Expand Down Expand Up @@ -65,6 +137,12 @@ export function BookmarkLinkReaderPreview({
}) {
const { isDarkColorScheme: isDark } = useColorScheme();
const { settings: readerSettings } = useReaderSettings();
const lastSavedOffset = useRef<number | null>(null);
const currentPosition = useRef<{
offset: number;
anchor: string;
percent: number;
} | null>(null);

const {
data: bookmarkWithContent,
Expand All @@ -76,6 +154,66 @@ export function BookmarkLinkReaderPreview({
includeContent: true,
});

const apiUtils = api.useUtils();
const { mutate: updateProgress } =
api.bookmarks.updateReadingProgress.useMutation({
onSuccess: () => {
apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: bookmark.id });
},
onError: (error) => {
console.error("[ReadingProgress] Failed to save progress:", error);
},
});

// Save progress function
const saveProgress = useCallback(() => {
if (currentPosition.current === null) return;

const { offset, anchor, percent } = currentPosition.current;

// Only save if offset has changed
if (lastSavedOffset.current !== offset) {
lastSavedOffset.current = offset;
updateProgress({
bookmarkId: bookmark.id,
readingProgressOffset: offset,
readingProgressAnchor: anchor,
readingProgressPercent: percent,
});
}
}, [bookmark.id, updateProgress]);

// Handle messages from WebView
const handleMessage = useCallback((event: WebViewMessageEvent) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === "SCROLL_PROGRESS" && typeof data.offset === "number") {
currentPosition.current = {
offset: data.offset,
anchor: typeof data.anchor === "string" ? data.anchor : "",
percent: typeof data.percent === "number" ? data.percent : 0,
};
}
} catch (error) {
console.warn("[ReadingProgress] Failed to parse WebView message:", error);
}
}, []);

// Save on AppState change (app going to background)
useEffect(() => {
const subscription = AppState.addEventListener("change", (status) => {
if (status === "background" || status === "inactive") {
saveProgress();
}
});

return () => {
// Save on unmount
saveProgress();
subscription.remove();
};
}, [saveProgress]);

if (isLoading) {
return <FullPageSpinner />;
}
Expand All @@ -92,6 +230,14 @@ export function BookmarkLinkReaderPreview({
const fontSize = readerSettings.fontSize;
const lineHeight = readerSettings.lineHeight;

// Get initial position for restoration
const initialOffset = bookmarkWithContent.content.readingProgressOffset ?? 0;
const initialAnchor =
bookmarkWithContent.content.readingProgressAnchor ?? null;

// Build the reading progress script with initial position
const injectedJS = buildReadingProgressScript(initialOffset, initialAnchor);

return (
<View className="flex-1 bg-background">
<WebView
Expand All @@ -111,6 +257,7 @@ export function BookmarkLinkReaderPreview({
margin: 0;
padding: 16px;
background: ${isDark ? "#000000" : "#ffffff"};
${initialOffset > 0 ? "opacity: 0;" : ""}
}
p { margin: 0 0 1em 0; }
h1, h2, h3, h4, h5, h6 { margin: 1.5em 0 0.5em 0; line-height: 1.2; }
Expand Down Expand Up @@ -156,6 +303,8 @@ export function BookmarkLinkReaderPreview({
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.998}
injectedJavaScript={injectedJS}
onMessage={handleMessage}
/>
</View>
);
Expand Down
35 changes: 33 additions & 2 deletions apps/web/app/reader/[bookmarkId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Suspense, useState } from "react";
import { Suspense, useCallback, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import HighlightCard from "@/components/dashboard/highlights/HighlightCard";
import ReaderSettingsPopover from "@/components/dashboard/preview/ReaderSettingsPopover";
Expand All @@ -12,6 +12,7 @@ import { useReaderSettings } from "@/lib/readerSettings";
import { HighlighterIcon as Highlight, Printer, X } from "lucide-react";
import { useSession } from "next-auth/react";

import { useReadingProgress } from "@karakeep/shared-react/hooks/reading-progress";
import { api } from "@karakeep/shared-react/trpc";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { READER_FONT_FAMILIES } from "@karakeep/shared/types/readers";
Expand All @@ -20,6 +21,7 @@ import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
export default function ReaderViewPage() {
const params = useParams<{ bookmarkId: string }>();
const bookmarkId = params.bookmarkId;
const contentRef = useRef<HTMLDivElement>(null);
const { data: highlights } = api.highlights.getForBookmark.useQuery({
bookmarkId,
});
Expand All @@ -33,6 +35,29 @@ export default function ReaderViewPage() {
const [showHighlights, setShowHighlights] = useState(false);
const isOwner = session?.user?.id === bookmark?.userId;

// Track when content is ready for reading progress restoration
const [contentReady, setContentReady] = useState(false);
const handleContentReady = useCallback(() => setContentReady(true), []);

// Get initial reading progress from bookmark content
const initialOffset =
bookmark?.content.type === BookmarkTypes.LINK
? bookmark.content.readingProgressOffset
: null;
const initialAnchor =
bookmark?.content.type === BookmarkTypes.LINK
? bookmark.content.readingProgressAnchor
: null;

// Auto-save reading progress on page unload/visibility change
const { isReady: isReadingPositionReady } = useReadingProgress({
bookmarkId,
initialOffset,
initialAnchor,
containerRef: contentRef,
contentReady,
});

const onClose = () => {
if (window.history.length > 1) {
router.back();
Expand Down Expand Up @@ -122,14 +147,20 @@ export default function ReaderViewPage() {
<Suspense fallback={<FullPageSpinner />}>
<div className="overflow-x-hidden">
<ReaderView
className="prose prose-neutral max-w-none break-words dark:prose-invert [&_code]:break-all [&_img]:h-auto [&_img]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
ref={contentRef}
className="prose prose-neutral max-w-none break-words dark:prose-invert [&_blockquote]:scroll-mt-16 [&_code]:break-all [&_h1]:scroll-mt-16 [&_h2]:scroll-mt-16 [&_h3]:scroll-mt-16 [&_h4]:scroll-mt-16 [&_h5]:scroll-mt-16 [&_h6]:scroll-mt-16 [&_img]:h-auto [&_img]:max-w-full [&_li]:scroll-mt-16 [&_p]:scroll-mt-16 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto"
style={{
fontFamily: READER_FONT_FAMILIES[settings.fontFamily],
fontSize: `${settings.fontSize}px`,
lineHeight: settings.lineHeight,
// Hide content until reading position is restored to prevent flicker
visibility: isReadingPositionReady
? "visible"
: "hidden",
}}
bookmarkId={bookmarkId}
readOnly={!isOwner}
onContentReady={handleContentReady}
/>
</div>
</Suspense>
Expand Down
39 changes: 27 additions & 12 deletions apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import React, { useEffect, useRef, useState } from "react";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent } from "@/components/ui/popover";
Expand Down Expand Up @@ -142,17 +148,26 @@ interface HTMLHighlighterProps {
onDeleteHighlight?: (highlight: Highlight) => void;
}

function BookmarkHTMLHighlighter({
htmlContent,
className,
style,
highlights = [],
readOnly = false,
onHighlight,
onUpdateHighlight,
onDeleteHighlight,
}: HTMLHighlighterProps) {
const BookmarkHTMLHighlighter = forwardRef<
HTMLDivElement,
HTMLHighlighterProps
>(function BookmarkHTMLHighlighter(
{
htmlContent,
className,
style,
highlights = [],
readOnly = false,
onHighlight,
onUpdateHighlight,
onDeleteHighlight,
},
ref,
) {
const contentRef = useRef<HTMLDivElement>(null);

// Expose the content div ref to parent components
useImperativeHandle(ref, () => contentRef.current!, []);
const [menuPosition, setMenuPosition] = useState<{
x: number;
y: number;
Expand Down Expand Up @@ -408,6 +423,6 @@ function BookmarkHTMLHighlighter({
/>
</div>
);
}
});

export default BookmarkHTMLHighlighter;
5 changes: 1 addition & 4 deletions apps/web/components/dashboard/preview/BookmarkPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,16 +217,13 @@ export default function BookmarkPreview({
{detailsSection}
</div>
</div>

{/* Render tabbed layout for narrow/vertical screens */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full w-full flex-col overflow-hidden lg:hidden"
>
<TabsList
className={`sticky top-0 z-10 grid h-auto w-full grid-cols-2`}
>
<TabsList className="sticky top-0 z-10 grid h-auto w-full grid-cols-2">
<TabsTrigger value="content">{t("preview.tabs.content")}</TabsTrigger>
<TabsTrigger value="details">{t("preview.tabs.details")}</TabsTrigger>
</TabsList>
Expand Down
Loading