11use stylist:: yew:: styled_component;
2+ #[ cfg( feature = "csr" ) ]
3+ use wasm_bindgen:: JsCast ;
24use yew:: prelude:: * ;
35
46use crate :: components:: footer:: Footer ;
@@ -45,7 +47,14 @@ fn version_slug(doc_version: &str) -> &str {
4547}
4648
4749pub fn rewrite_doc_href ( href : & str , lang : & str , doc_version : & str ) -> String {
48- if let Some ( rest) = href. strip_prefix ( "/docs/" ) {
50+ let after_lang = if lang. is_empty ( ) {
51+ href
52+ } else {
53+ let prefix = format ! ( "/{lang}" ) ;
54+ href. strip_prefix ( & prefix) . unwrap_or ( href)
55+ } ;
56+
57+ if let Some ( rest) = after_lang. strip_prefix ( "/docs/" ) {
4958 let lang_p = if lang. is_empty ( ) {
5059 String :: new ( )
5160 } else {
@@ -62,6 +71,34 @@ pub fn rewrite_doc_href(href: &str, lang: &str, doc_version: &str) -> String {
6271 }
6372}
6473
74+ fn edit_page_url ( active_path : & str , lang : & str ) -> String {
75+ const BASE : & str = "https://github.com/yewstack/yew/blob/master/yew-rs" ;
76+
77+ let crate_dir = if lang. is_empty ( ) {
78+ "docs" . to_string ( )
79+ } else {
80+ format ! ( "docs-{}" , lang. to_lowercase( ) )
81+ } ;
82+
83+ let bare = if lang. is_empty ( ) {
84+ active_path
85+ } else {
86+ let pfx = format ! ( "/{lang}" ) ;
87+ active_path. strip_prefix ( & pfx) . unwrap_or ( active_path)
88+ } ;
89+
90+ let Some ( page_path) = bare. strip_prefix ( "/docs/" ) else {
91+ return String :: new ( ) ;
92+ } ;
93+ let file_path: String = page_path
94+ . split ( '/' )
95+ . map ( |seg| seg. replace ( '-' , "_" ) )
96+ . collect :: < Vec < _ > > ( )
97+ . join ( "/" ) ;
98+
99+ format ! ( "{BASE}/{crate_dir}/src/pages/{file_path}.rs" )
100+ }
101+
65102fn build_breadcrumbs (
66103 entries : & [ SidebarEntry ] ,
67104 active_path : & str ,
@@ -306,6 +343,28 @@ pub fn Layout(props: &LayoutProps) -> Html {
306343 color: var(--color-primary);
307344 }
308345
346+ .edit-page {
347+ margin-top: 2rem;
348+ }
349+
350+ .edit-page-link {
351+ display: inline-flex;
352+ align-items: center;
353+ gap: 0.375rem;
354+ color: var(--color-text-secondary);
355+ font-size: 0.875rem;
356+ text-decoration: none;
357+ transition: color 0.2s;
358+ }
359+
360+ .edit-page-link:hover {
361+ color: var(--color-primary);
362+ }
363+
364+ .edit-page-icon {
365+ flex-shrink: 0;
366+ }
367+
309368 .toc-column {
310369 width: 250px;
311370 flex-shrink: 0;
@@ -434,6 +493,12 @@ pub fn Layout(props: &LayoutProps) -> Html {
434493 ( prev, next)
435494 } ) ;
436495
496+ let edit_url = if has_sidebar {
497+ edit_page_url ( props. active_sidebar_path . as_str ( ) , props. lang . as_str ( ) )
498+ } else {
499+ String :: new ( )
500+ } ;
501+
437502 #[ cfg( feature = "csr" ) ]
438503 let copy_md_button = if !props. full_width && !props. markdown . is_empty ( ) {
439504 html ! {
@@ -529,6 +594,16 @@ pub fn Layout(props: &LayoutProps) -> Html {
529594 <h1 class="page-title" >{ & props. title} </h1>
530595 }
531596 { props. children. clone( ) }
597+ if !edit_url. is_empty( ) {
598+ <div class="edit-page" >
599+ <a class="edit-page-link" href={ edit_url. clone( ) } target="_blank" rel="noopener noreferrer" >
600+ <svg viewBox="0 0 24 24" width="16" height="16" class="edit-page-icon" >
601+ <path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
602+ </svg>
603+ { "Edit this page" }
604+ </a>
605+ </div>
606+ }
532607 if let Some ( ( prev, next) ) = & pagination {
533608 <nav class="pagination" aria-label="Docs pages" >
534609 if let Some ( ( label, href) ) = prev {
@@ -549,25 +624,7 @@ pub fn Layout(props: &LayoutProps) -> Html {
549624 }
550625 </main>
551626 if has_sidebar && !props. toc. is_empty( ) {
552- <aside class="toc-column" >
553- <nav class="toc-container" >
554- <ul class="toc-list" >
555- { for props. toc. iter( ) . map( |entry| {
556- let pad = match entry. level {
557- 3 => "padding-left:1rem" ,
558- 4 => "padding-left:2rem" ,
559- _ => "" ,
560- } ;
561- let href = format!( "#{}" , entry. id) ;
562- html! {
563- <li style={ pad} >
564- <a class="toc-link" href={ href} >{ & entry. text} </a>
565- </li>
566- }
567- } ) }
568- </ul>
569- </nav>
570- </aside>
627+ <Toc entries={ props. toc. clone( ) } />
571628 }
572629 </div>
573630 <Footer />
@@ -577,37 +634,157 @@ pub fn Layout(props: &LayoutProps) -> Html {
577634
578635#[ cfg( feature = "csr" ) ]
579636fn init_page_features ( ) {
580- let _ = js_sys:: eval ( PAGE_FEATURES_JS ) ;
637+ let window = match web_sys:: window ( ) {
638+ Some ( w) => w,
639+ None => return ,
640+ } ;
641+ let hash = window. location ( ) . hash ( ) . unwrap_or_default ( ) ;
642+ if !hash. is_empty ( ) {
643+ let document = match window. document ( ) {
644+ Some ( d) => d,
645+ None => return ,
646+ } ;
647+ if let Some ( el) = document. get_element_by_id ( & hash[ 1 ..] ) {
648+ gloo:: timers:: callback:: Timeout :: new ( 100 , move || {
649+ el. scroll_into_view ( ) ;
650+ } )
651+ . forget ( ) ;
652+ }
653+ }
581654}
582655
583656#[ cfg( not( feature = "csr" ) ) ]
584657fn init_page_features ( ) { }
585658
659+ #[ derive( Clone , PartialEq , Properties ) ]
660+ struct TocProps {
661+ entries : Vec < TocEntry > ,
662+ }
663+
586664#[ cfg( feature = "csr" ) ]
587- const PAGE_FEATURES_JS : & str = r#"
588- (function() {
589- var content = document.querySelector('.doc-content');
590- if (!content) return;
591-
592- var headings = content.querySelectorAll('h2.anchor, h3.anchor, h4.anchor');
593- var tc = document.querySelector('.toc-container');
594- if (tc && headings.length > 0) {
595- var links = tc.querySelectorAll('.toc-link');
596- var obs = new IntersectionObserver(function(entries) {
597- entries.forEach(function(e) {
598- if (e.isIntersecting) {
599- links.forEach(function(l) {
600- l.classList.toggle('toc-link--active', l.getAttribute('href') === '#' + e.target.id);
601- });
665+ #[ function_component]
666+ fn Toc ( props : & TocProps ) -> Html {
667+ let active_id = use_state ( || Option :: < AttrValue > :: None ) ;
668+
669+ {
670+ let active_id = active_id. clone ( ) ;
671+ let entries = props. entries . clone ( ) ;
672+ use_effect_with ( entries. clone ( ) , move |_| {
673+ let window = web_sys:: window ( ) . unwrap ( ) ;
674+ let document = window. document ( ) . unwrap ( ) ;
675+
676+ let navbar_height: f64 = document
677+ . query_selector ( ".navbar" )
678+ . ok ( )
679+ . flatten ( )
680+ . map ( |el| {
681+ let html: web_sys:: HtmlElement = el. unchecked_into ( ) ;
682+ html. client_height ( ) as f64
683+ } )
684+ . unwrap_or ( 60.0 ) ;
685+
686+ let ids: Vec < AttrValue > = entries. iter ( ) . map ( |e| e. id . clone ( ) ) . collect ( ) ;
687+
688+ let compute = {
689+ let ids = ids. clone ( ) ;
690+ let active_id = active_id. clone ( ) ;
691+ let document = document. clone ( ) ;
692+ let window = window. clone ( ) ;
693+ move || {
694+ let mut active: Option < AttrValue > = None ;
695+ let mut next_visible_idx: Option < usize > = None ;
696+
697+ for ( i, id) in ids. iter ( ) . enumerate ( ) {
698+ if let Some ( el) = document. get_element_by_id ( id. as_str ( ) ) {
699+ let rect = el. get_bounding_client_rect ( ) ;
700+ if rect. top ( ) >= navbar_height {
701+ next_visible_idx = Some ( i) ;
702+ break ;
703+ }
704+ }
705+ }
706+
707+ if let Some ( idx) = next_visible_idx {
708+ if let Some ( el) = document. get_element_by_id ( ids[ idx] . as_str ( ) ) {
709+ let rect = el. get_bounding_client_rect ( ) ;
710+ let vh = window
711+ . inner_height ( )
712+ . ok ( )
713+ . and_then ( |v| v. as_f64 ( ) )
714+ . unwrap_or ( 800.0 ) ;
715+ if rect. top ( ) > 0.0 && rect. bottom ( ) < vh / 2.0 {
716+ active = Some ( ids[ idx] . clone ( ) ) ;
717+ } else if idx > 0 {
718+ active = Some ( ids[ idx - 1 ] . clone ( ) ) ;
719+ }
720+ }
721+ } else if !ids. is_empty ( ) {
722+ active = Some ( ids[ ids. len ( ) - 1 ] . clone ( ) ) ;
723+ }
724+
725+ active_id. set ( active) ;
602726 }
603- });
604- }, { rootMargin: '-60px 0px -66% 0px', threshold: 0 });
605- headings.forEach(function(h) { obs.observe(h); });
727+ } ;
728+
729+ compute ( ) ;
730+
731+ let compute_scroll = compute. clone ( ) ;
732+ let scroll_listener =
733+ gloo:: events:: EventListener :: new ( & document, "scroll" , move |_| compute_scroll ( ) ) ;
734+
735+ let resize_listener =
736+ gloo:: events:: EventListener :: new ( & window, "resize" , move |_| compute ( ) ) ;
737+
738+ move || drop ( ( scroll_listener, resize_listener) )
739+ } ) ;
740+ }
741+
742+ html ! {
743+ <aside class="toc-column" >
744+ <nav class="toc-container" >
745+ <ul class="toc-list" >
746+ { for props. entries. iter( ) . map( |entry| {
747+ let pad = match entry. level {
748+ 3 => "padding-left:1rem" ,
749+ 4 => "padding-left:2rem" ,
750+ _ => "" ,
751+ } ;
752+ let href = format!( "#{}" , entry. id) ;
753+ let is_active = * active_id == Some ( entry. id. clone( ) ) ;
754+ html! {
755+ <li style={ pad} >
756+ <a class={ classes!( "toc-link" , is_active. then_some( "toc-link--active" ) ) } href={ href} >{ & entry. text} </a>
757+ </li>
758+ }
759+ } ) }
760+ </ul>
761+ </nav>
762+ </aside>
606763 }
764+ }
607765
608- if (location.hash) {
609- var el = document.getElementById(location.hash.slice(1));
610- if (el) setTimeout(function() { el.scrollIntoView(); }, 100);
766+ #[ cfg( not( feature = "csr" ) ) ]
767+ #[ function_component]
768+ fn Toc ( props : & TocProps ) -> Html {
769+ html ! {
770+ <aside class="toc-column" >
771+ <nav class="toc-container" >
772+ <ul class="toc-list" >
773+ { for props. entries. iter( ) . map( |entry| {
774+ let pad = match entry. level {
775+ 3 => "padding-left:1rem" ,
776+ 4 => "padding-left:2rem" ,
777+ _ => "" ,
778+ } ;
779+ let href = format!( "#{}" , entry. id) ;
780+ html! {
781+ <li style={ pad} >
782+ <a class="toc-link" href={ href} >{ & entry. text} </a>
783+ </li>
784+ }
785+ } ) }
786+ </ul>
787+ </nav>
788+ </aside>
611789 }
612- })();
613- "# ;
790+ }
0 commit comments