@@ -1037,18 +1037,58 @@ function renderArticlePage(slug: string) {
10371037 ${ renderArticleReaderNavigation ( article . toc ) }
10381038 </div>
10391039 </main>
1040+ ${ renderArticleImageLightbox ( ) }
10401041 ${ renderFooter ( ) }
10411042 `
10421043}
10431044
1045+ function renderArticleImageLightbox ( ) : string {
1046+ return `
1047+ <div class="article-image-lightbox" role="dialog" aria-modal="true" aria-label="Fullscreen article image" hidden data-article-lightbox>
1048+ <button class="article-image-lightbox-backdrop" type="button" aria-label="Close fullscreen image" data-article-lightbox-close></button>
1049+ <figure class="article-image-lightbox-frame">
1050+ <button class="article-image-lightbox-close demo-player-fullscreen" type="button" aria-label="Close fullscreen image" title="Close" data-article-lightbox-close>
1051+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
1052+ <path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round"/>
1053+ </svg>
1054+ </button>
1055+ <img src="" alt="" data-article-lightbox-preview />
1056+ <figcaption data-article-lightbox-caption hidden></figcaption>
1057+ </figure>
1058+ </div>
1059+ `
1060+ }
1061+
10441062function renderArticleAuthor ( author : ArticleAuthor ) : string {
10451063 const label = `By ${ author . name } `
1064+ const avatar = author . avatar
1065+ ? `<img class="article-author-avatar" src="${ escapeHtml ( authorAvatarAssetFor ( author . avatar ) ) } " alt="${ escapeHtml ( `${ author . name } avatar` ) } " loading="lazy" />`
1066+ : ''
1067+ const content = `
1068+ ${ avatar }
1069+ <span class="article-author-label">${ escapeHtml ( label ) } </span>
1070+ `
10461071
10471072 if ( ! author . url || ! isSafeAuthorUrl ( author . url ) ) {
1048- return `<span>${ escapeHtml ( label ) } </span>`
1073+ return `<span class="article-author" >${ content } </span>`
10491074 }
10501075
1051- return `<a class="article-author-link" href="${ escapeHtml ( author . url ) } " target="_blank" rel="author noreferrer">${ escapeHtml ( label ) } </a>`
1076+ return `
1077+ <a class="article-author article-author-link" href="${ escapeHtml ( author . url ) } " target="_blank" rel="author noreferrer">
1078+ ${ content }
1079+ <svg class="article-author-arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
1080+ <path d="M5 3.5h7.5V11M12.25 3.75 4 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
1081+ </svg>
1082+ </a>
1083+ `
1084+ }
1085+
1086+ function authorAvatarAssetFor ( path : string ) : string {
1087+ if ( / ^ ( h t t p s ? : | d a t a : | b l o b : | \/ ) / . test ( path ) || path . startsWith ( 'images/' ) ) {
1088+ return assetFor ( path )
1089+ }
1090+
1091+ return assetFor ( `images/${ path } ` )
10521092}
10531093
10541094function renderArticleTocLinks ( toc : ArticleHeading [ ] , context : 'desktop' | 'mobile' ) : string {
@@ -1135,6 +1175,7 @@ function setupArticleReadingNavigation() {
11351175 if ( ! content || ! headings . length || ! links . length ) return
11361176
11371177 let mobileSheetCloseTimer : number | undefined
1178+ let lastActiveId : string | undefined
11381179
11391180 const setMobileSheetOpen = ( isOpen : boolean ) => {
11401181 if ( ! mobileToggle || ! mobileSheet || ! mobileNav ) return
@@ -1165,6 +1206,28 @@ function setupArticleReadingNavigation() {
11651206 setMobileSheetOpen ( false )
11661207 }
11671208
1209+ const scrollTocLinkIntoView = ( link : HTMLElement ) => {
1210+ const container = link . closest < HTMLElement > ( '.article-toc-list, .article-mobile-toc-list' )
1211+ if ( ! container ) return
1212+
1213+ const containerRect = container . getBoundingClientRect ( )
1214+ const linkRect = link . getBoundingClientRect ( )
1215+ const scrollPadding = 16
1216+ const isAboveView = linkRect . top < containerRect . top + scrollPadding
1217+ const isBelowView = linkRect . bottom > containerRect . bottom - scrollPadding
1218+
1219+ if ( ! isAboveView && ! isBelowView ) return
1220+
1221+ const scrollDelta = isAboveView
1222+ ? linkRect . top - containerRect . top - scrollPadding
1223+ : linkRect . bottom - containerRect . bottom + scrollPadding
1224+
1225+ container . scrollTo ( {
1226+ top : container . scrollTop + scrollDelta ,
1227+ behavior : 'smooth' ,
1228+ } )
1229+ }
1230+
11681231 links . forEach ( ( link ) => {
11691232 link . addEventListener ( 'click' , ( event ) => {
11701233 const headingId = link . dataset . articleTocLink
@@ -1203,6 +1266,9 @@ function setupArticleReadingNavigation() {
12031266 const progress = contentEnd <= contentStart ? 1 : Math . min ( 1 , Math . max ( 0 , ( window . scrollY - contentStart ) / ( contentEnd - contentStart ) ) )
12041267 const progressText = `${ Math . round ( progress * 100 ) } %`
12051268 const activeTitle = activeHeading . textContent ?. trim ( ) || 'Start'
1269+ const didActiveSectionChange = activeId !== lastActiveId
1270+
1271+ lastActiveId = activeId
12061272
12071273 progressFills . forEach ( ( fill ) => {
12081274 fill . style . transform = `scaleX(${ progress } )`
@@ -1217,6 +1283,10 @@ function setupArticleReadingNavigation() {
12171283 const isActive = link . dataset . articleTocLink === activeId
12181284 link . classList . toggle ( 'is-active' , isActive )
12191285 link . setAttribute ( 'aria-current' , isActive ? 'true' : 'false' )
1286+
1287+ if ( isActive && didActiveSectionChange ) {
1288+ scrollTocLinkIntoView ( link )
1289+ }
12201290 } )
12211291 }
12221292
@@ -1341,6 +1411,58 @@ function setupInteractions() {
13411411 setupDemoFullscreen ( )
13421412 setupDemoAmbientLight ( )
13431413 setupArticleReadingNavigation ( )
1414+ setupArticleImageLightbox ( )
1415+ }
1416+
1417+ function setupArticleImageLightbox ( ) {
1418+ const lightbox = document . querySelector < HTMLElement > ( '[data-article-lightbox]' )
1419+ const preview = lightbox ?. querySelector < HTMLImageElement > ( '[data-article-lightbox-preview]' )
1420+ const caption = lightbox ?. querySelector < HTMLElement > ( '[data-article-lightbox-caption]' )
1421+ const closeButton = lightbox ?. querySelector < HTMLButtonElement > ( '.article-image-lightbox-close' )
1422+ let previouslyFocused : Element | null = null
1423+
1424+ if ( ! lightbox || ! preview || ! caption ) return
1425+
1426+ const closeLightbox = ( ) => {
1427+ lightbox . hidden = true
1428+ preview . removeAttribute ( 'src' )
1429+ document . body . classList . remove ( 'article-lightbox-open' )
1430+
1431+ if ( previouslyFocused instanceof HTMLElement ) {
1432+ previouslyFocused . focus ( )
1433+ }
1434+ }
1435+
1436+ const openLightbox = ( image : HTMLImageElement ) => {
1437+ const figure = image . closest ( 'figure' )
1438+ const figureCaption = figure ?. querySelector ( 'figcaption' ) ?. textContent ?. trim ( ) ?? ''
1439+
1440+ previouslyFocused = document . activeElement
1441+ preview . src = image . currentSrc || image . src
1442+ preview . alt = image . alt
1443+ caption . textContent = figureCaption
1444+ caption . hidden = ! figureCaption
1445+ lightbox . hidden = false
1446+ document . body . classList . add ( 'article-lightbox-open' )
1447+ closeButton ?. focus ( )
1448+ }
1449+
1450+ document . querySelectorAll < HTMLImageElement > ( '[data-article-lightbox-image]' ) . forEach ( ( image ) => {
1451+ image . addEventListener ( 'click' , ( ) => openLightbox ( image ) )
1452+ image . addEventListener ( 'keydown' , ( event ) => {
1453+ if ( event . key !== 'Enter' && event . key !== ' ' ) return
1454+ event . preventDefault ( )
1455+ openLightbox ( image )
1456+ } )
1457+ } )
1458+
1459+ lightbox . querySelectorAll ( '[data-article-lightbox-close]' ) . forEach ( ( control ) => {
1460+ control . addEventListener ( 'click' , closeLightbox )
1461+ } )
1462+
1463+ document . addEventListener ( 'keydown' , ( event ) => {
1464+ if ( event . key === 'Escape' && ! lightbox . hidden ) closeLightbox ( )
1465+ } )
13441466}
13451467
13461468function setupDemoFullscreen ( ) {
0 commit comments