@@ -150,6 +150,9 @@ function getInputCategories(input: SubscriptionInput): string[] {
150150const AUTO_REFRESH_OPTION_VALUES = [ 5 , 10 , 15 , 30 , 60 , 120 , 180 , 360 , 720 , 1440 ] ;
151151const PARALLEL_FETCH_OPTION_VALUES = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ] ;
152152const 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 = / h t t p s ? : \/ \/ [ ^ \s < > " ' ] + / g;
155+ const IMAGE_URL_PATTERN = / h t t p s ? : \/ \/ [ ^ \s < > " ' ] + ?\. (?: j p e ? g | p n g | g i f | w e b p | a v i f ) (?: \? [ ^ \s < > " ' ] * ) ? (?: # [ ^ \s < > " ' ] * ) ? / gi;
153156
154157function 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"
0 commit comments