Skip to content

Commit ddaa49d

Browse files
committed
Add reader image enhancement previews
1 parent 164c5a7 commit ddaa49d

2 files changed

Lines changed: 267 additions & 5 deletions

File tree

garss-studio/src/App.tsx

Lines changed: 231 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ function getInputCategories(input: SubscriptionInput): string[] {
150150
const AUTO_REFRESH_OPTION_VALUES = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440];
151151
const PARALLEL_FETCH_OPTION_VALUES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
152152
const SOURCE_DRAFT_STORAGE_KEY = "garss-studio.source-draft";
153+
const ARTICLE_IMAGE_ENHANCEMENT_STORAGE_KEY = "garss-studio.article-image-enhancement";
154+
const INLINE_URL_PATTERN = /https?:\/\/[^\s<>"']+/g;
155+
const IMAGE_URL_PATTERN = /https?:\/\/[^\s<>"']+?\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s<>"']*)?(?:#[^\s<>"']*)?/gi;
153156

154157
function buildSortedOptions(defaultValues: number[], currentValue: number): number[] {
155158
return Array.from(new Set([...defaultValues, currentValue])).sort((left, right) => left - right);
@@ -202,8 +205,212 @@ function splitIntoParagraphs(value: string): string[] {
202205
.filter(Boolean);
203206
}
204207

205-
function ReaderArticleContent({ html }: { html: string }) {
208+
function readStoredArticleImageEnhancementEnabled(): boolean {
209+
if (typeof window === "undefined") {
210+
return true;
211+
}
212+
213+
return window.localStorage.getItem(ARTICLE_IMAGE_ENHANCEMENT_STORAGE_KEY) !== "off";
214+
}
215+
216+
function writeStoredArticleImageEnhancementEnabled(value: boolean): void {
217+
if (typeof window === "undefined") {
218+
return;
219+
}
220+
221+
window.localStorage.setItem(ARTICLE_IMAGE_ENHANCEMENT_STORAGE_KEY, value ? "on" : "off");
222+
}
223+
224+
function buildImageProxyUrl(value: string): string {
225+
if (!value || typeof window === "undefined") {
226+
return value;
227+
}
228+
229+
try {
230+
const imageUrl = new URL(value, window.location.href);
231+
232+
if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") {
233+
return value;
234+
}
235+
236+
if (imageUrl.origin === window.location.origin && imageUrl.pathname === "/api/image-proxy") {
237+
return imageUrl.toString();
238+
}
239+
240+
return `/api/image-proxy?url=${encodeURIComponent(imageUrl.toString())}`;
241+
} catch {
242+
return value;
243+
}
244+
}
245+
246+
function normalizeArticleUrlCandidate(value: string): string {
247+
return value.trim().replace(/[),.;\]}]+$/u, "");
248+
}
249+
250+
function isImageUrl(value: string): boolean {
251+
IMAGE_URL_PATTERN.lastIndex = 0;
252+
return IMAGE_URL_PATTERN.test(value);
253+
}
254+
255+
function extractImageUrls(value: string): string[] {
256+
IMAGE_URL_PATTERN.lastIndex = 0;
257+
const matches = value.match(IMAGE_URL_PATTERN) || [];
258+
return matches.map(normalizeArticleUrlCandidate);
259+
}
260+
261+
function createArticleImagePreview(documentRef: Document, value: string): HTMLElement {
262+
const normalizedUrl = normalizeArticleUrlCandidate(value);
263+
const linkElement = documentRef.createElement("a");
264+
linkElement.className = "reader-article-image-preview";
265+
linkElement.href = normalizedUrl;
266+
linkElement.target = "_blank";
267+
linkElement.rel = "noreferrer";
268+
269+
const imageElement = documentRef.createElement("img");
270+
imageElement.src = buildImageProxyUrl(normalizedUrl);
271+
imageElement.alt = "文章图片";
272+
imageElement.loading = "lazy";
273+
imageElement.decoding = "async";
274+
imageElement.referrerPolicy = "no-referrer";
275+
276+
linkElement.appendChild(imageElement);
277+
return linkElement;
278+
}
279+
280+
function insertImagePreviewsIntoUrlBlocks(template: HTMLTemplateElement): void {
281+
for (const preElement of Array.from(template.content.querySelectorAll("pre"))) {
282+
if (preElement.closest("td.gutter")) {
283+
continue;
284+
}
285+
286+
const rawLines = (preElement.textContent || "").split(/\n/);
287+
const nonEmptyLines = rawLines
288+
.map((line) => line.trim())
289+
.filter(Boolean);
290+
const imageUrls = nonEmptyLines.flatMap(extractImageUrls);
291+
292+
if (!imageUrls.length || imageUrls.length / nonEmptyLines.length < 0.6) {
293+
continue;
294+
}
295+
296+
const fragment = document.createDocumentFragment();
297+
298+
rawLines.forEach((rawLine, index) => {
299+
const lineImageUrls = extractImageUrls(rawLine);
300+
301+
fragment.appendChild(document.createTextNode(rawLine));
302+
303+
for (const imageUrl of lineImageUrls) {
304+
fragment.appendChild(document.createTextNode("\n"));
305+
fragment.appendChild(createArticleImagePreview(document, imageUrl));
306+
}
307+
308+
if (index < rawLines.length - 1) {
309+
fragment.appendChild(document.createTextNode("\n"));
310+
}
311+
});
312+
313+
while (preElement.firstChild) {
314+
preElement.firstChild.remove();
315+
}
316+
317+
preElement.appendChild(fragment);
318+
}
319+
}
320+
321+
function enhanceInlineImageUrls(template: HTMLTemplateElement): void {
322+
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_TEXT);
323+
const textNodes: Text[] = [];
324+
325+
while (walker.nextNode()) {
326+
const textNode = walker.currentNode as Text;
327+
const parentElement = textNode.parentElement;
328+
329+
if (
330+
!parentElement ||
331+
parentElement.closest("a, code, pre, script, style, textarea, .reader-article-image-list")
332+
) {
333+
continue;
334+
}
335+
336+
if (INLINE_URL_PATTERN.test(textNode.nodeValue || "")) {
337+
textNodes.push(textNode);
338+
}
339+
340+
INLINE_URL_PATTERN.lastIndex = 0;
341+
}
342+
343+
for (const textNode of textNodes) {
344+
const textValue = textNode.nodeValue || "";
345+
const fragment = document.createDocumentFragment();
346+
let cursor = 0;
347+
348+
for (const match of textValue.matchAll(INLINE_URL_PATTERN)) {
349+
const rawUrl = match[0];
350+
const matchIndex = match.index || 0;
351+
352+
if (matchIndex > cursor) {
353+
fragment.appendChild(document.createTextNode(textValue.slice(cursor, matchIndex)));
354+
}
355+
356+
const normalizedUrl = normalizeArticleUrlCandidate(rawUrl);
357+
358+
const linkElement = document.createElement("a");
359+
linkElement.href = normalizedUrl;
360+
linkElement.target = "_blank";
361+
linkElement.rel = "noreferrer";
362+
linkElement.textContent = normalizedUrl;
363+
fragment.appendChild(linkElement);
364+
365+
if (isImageUrl(normalizedUrl)) {
366+
fragment.appendChild(createArticleImagePreview(document, normalizedUrl));
367+
}
368+
369+
cursor = matchIndex + rawUrl.length;
370+
371+
if (normalizedUrl.length < rawUrl.length) {
372+
fragment.appendChild(document.createTextNode(rawUrl.slice(normalizedUrl.length)));
373+
}
374+
}
375+
376+
if (cursor < textValue.length) {
377+
fragment.appendChild(document.createTextNode(textValue.slice(cursor)));
378+
}
379+
380+
textNode.replaceWith(fragment);
381+
}
382+
}
383+
384+
function enhanceArticleHtml(html: string, shouldEnhanceImageUrls: boolean): string {
385+
if (!html || typeof window === "undefined") {
386+
return html;
387+
}
388+
389+
const template = document.createElement("template");
390+
template.innerHTML = html;
391+
392+
for (const imageElement of Array.from(template.content.querySelectorAll("img"))) {
393+
const source = imageElement.getAttribute("src") || "";
394+
const proxiedSource = buildImageProxyUrl(source);
395+
396+
if (proxiedSource) {
397+
imageElement.setAttribute("src", proxiedSource);
398+
}
399+
400+
imageElement.setAttribute("referrerpolicy", "no-referrer");
401+
}
402+
403+
if (shouldEnhanceImageUrls) {
404+
insertImagePreviewsIntoUrlBlocks(template);
405+
enhanceInlineImageUrls(template);
406+
}
407+
408+
return template.innerHTML;
409+
}
410+
411+
function ReaderArticleContent({ html, shouldEnhanceImageUrls }: { html: string; shouldEnhanceImageUrls: boolean }) {
206412
const contentRef = useRef<HTMLDivElement | null>(null);
413+
const enhancedHtml = useMemo(() => enhanceArticleHtml(html, shouldEnhanceImageUrls), [html, shouldEnhanceImageUrls]);
207414

208415
useEffect(() => {
209416
const contentElement = contentRef.current;
@@ -284,13 +491,13 @@ function ReaderArticleContent({ html }: { html: string }) {
284491
cleanup();
285492
}
286493
};
287-
}, [html]);
494+
}, [enhancedHtml]);
288495

289496
return (
290497
<div
291498
ref={contentRef}
292499
className="reader-article-content"
293-
dangerouslySetInnerHTML={{ __html: html }}
500+
dangerouslySetInnerHTML={{ __html: enhancedHtml }}
294501
/>
295502
);
296503
}
@@ -440,10 +647,12 @@ function ReaderArticleCard({
440647
item,
441648
isActive,
442649
articleRef,
650+
shouldEnhanceImageUrls,
443651
}: {
444652
item: FeedItem;
445653
isActive: boolean;
446654
articleRef?: (node: HTMLElement | null) => void;
655+
shouldEnhanceImageUrls: boolean;
447656
}) {
448657
const paragraphs = splitIntoParagraphs(item.contentText || item.excerpt);
449658

@@ -470,7 +679,7 @@ function ReaderArticleCard({
470679
<section className="reader-article-section">
471680
<h2>{item.title || "未命名条目"}</h2>
472681
{item.contentHtml ? (
473-
<ReaderArticleContent html={item.contentHtml} />
682+
<ReaderArticleContent html={item.contentHtml} shouldEnhanceImageUrls={shouldEnhanceImageUrls} />
474683
) : (
475684
<div className="reader-article-content is-plain">
476685
{paragraphs.length ? (
@@ -548,6 +757,7 @@ function ReaderPanel() {
548757
const [sourceFilterQuery, setSourceFilterQuery] = useState("");
549758
const [readerNavigationMode, setReaderNavigationMode] = useState<ReaderNavigationMode>("pure");
550759
const [expandedReaderDate, setExpandedReaderDate] = useState("");
760+
const [shouldEnhanceImageUrls, setShouldEnhanceImageUrls] = useState(readStoredArticleImageEnhancementEnabled);
551761
const articleScrollRef = useRef<HTMLDivElement | null>(null);
552762

553763
const baseSourceStateList = useMemo(
@@ -838,6 +1048,14 @@ function ReaderPanel() {
8381048
}
8391049
}
8401050

1051+
function handleToggleImageEnhancement() {
1052+
setShouldEnhanceImageUrls((currentValue) => {
1053+
const nextValue = !currentValue;
1054+
writeStoredArticleImageEnhancementEnabled(nextValue);
1055+
return nextValue;
1056+
});
1057+
}
1058+
8411059
return (
8421060
<section className="content-panel reader-panel">
8431061
<div className="reader-layout">
@@ -1027,6 +1245,14 @@ function ReaderPanel() {
10271245
? `重新拉取 ${selectedSourceState.subscriptionName} 中...`
10281246
: `重新拉取 ${selectedSourceState.subscriptionName}`}
10291247
</button>
1248+
<button
1249+
type="button"
1250+
className={`secondary-button reader-image-enhancement-toggle${shouldEnhanceImageUrls ? " is-active" : ""}`}
1251+
aria-pressed={shouldEnhanceImageUrls}
1252+
onClick={handleToggleImageEnhancement}
1253+
>
1254+
图片增强{shouldEnhanceImageUrls ? "开" : "关"}
1255+
</button>
10301256
</div>
10311257
) : null}
10321258

@@ -1042,7 +1268,7 @@ function ReaderPanel() {
10421268
{selectedItem ? (
10431269
<div className="reader-detail-layout">
10441270
<div ref={articleScrollRef} className="reader-article-stream">
1045-
<ReaderArticleCard item={selectedItem} isActive />
1271+
<ReaderArticleCard item={selectedItem} isActive shouldEnhanceImageUrls={shouldEnhanceImageUrls} />
10461272
<nav className="reader-article-pagination" aria-label="文章翻页">
10471273
<button
10481274
type="button"

garss-studio/src/styles.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,19 @@ a {
906906
border-radius: 0;
907907
}
908908

909+
.reader-main-toolbar .reader-image-enhancement-toggle {
910+
flex: 0 0 auto;
911+
min-width: 112px;
912+
padding-inline: 14px;
913+
}
914+
915+
.reader-main-toolbar .reader-image-enhancement-toggle.is-active {
916+
background: rgb(241, 236, 225);
917+
color: rgb(111, 66, 25);
918+
font-weight: 700;
919+
box-shadow: rgba(0, 0, 0, 0.045) 0 1px 5px 0 inset;
920+
}
921+
909922

910923
.sources-sidebar-title {
911924
gap: 12px;
@@ -1960,6 +1973,29 @@ a {
19601973
box-shadow: 0 16px 28px rgba(71, 48, 24, 0.12);
19611974
}
19621975

1976+
.reader-article-image-list {
1977+
display: grid;
1978+
gap: 18px;
1979+
margin: 0 0 18px;
1980+
}
1981+
1982+
.reader-article-image-preview {
1983+
display: block;
1984+
max-width: 100%;
1985+
color: inherit;
1986+
text-decoration: none;
1987+
}
1988+
1989+
.reader-article-content pre .reader-article-image-preview {
1990+
margin: 10px 0 18px;
1991+
}
1992+
1993+
.reader-article-content .reader-article-image-preview img {
1994+
width: auto;
1995+
max-height: min(720px, 72vh);
1996+
object-fit: contain;
1997+
}
1998+
19631999
.reader-article-content blockquote {
19642000
padding: 8px 0 8px 18px;
19652001
border-left: 3px solid rgba(143, 92, 44, 0.22);

0 commit comments

Comments
 (0)