|
1 |
| -import { |
2 |
| - $, |
3 |
| - QwikIntrinsicElements, |
4 |
| - component$, |
5 |
| - useSignal, |
6 |
| - useVisibleTask$, |
7 |
| -} from '@builder.io/qwik'; |
8 | 1 | import { ContentHeading } from '@builder.io/qwik-city';
|
9 | 2 | import { cn } from '@qwik-ui/utils';
|
| 3 | +import { component$, useSignal, useVisibleTask$, $ } from '@builder.io/qwik'; |
10 | 4 |
|
11 | 5 | export const DashboardTableOfContents = component$(
|
12 | 6 | ({ headings }: { headings: ContentHeading[] }) => {
|
13 |
| - const itemIds = headings.map((item) => item.id); |
14 |
| - const activeHeading = useActiveItem(itemIds); |
15 |
| - |
16 | 7 | if (headings.length === 0) {
|
17 | 8 | return null;
|
18 | 9 | }
|
19 |
| - |
20 | 10 | return (
|
21 | 11 | <div class="space-y-2">
|
22 | 12 | <div class="font-medium">On This Page</div>
|
23 |
| - <Tree headings={headings} activeItem={activeHeading.value} /> |
| 13 | + <TableOfContent headings={headings} /> |
24 | 14 | </div>
|
25 | 15 | );
|
26 | 16 | },
|
27 | 17 | );
|
28 | 18 |
|
| 19 | +type TableOfContentProps = { headings: ContentHeading[] }; |
| 20 | +interface Node extends ContentHeading { |
| 21 | + level: number; |
| 22 | + children: Array<Node>; |
| 23 | + activeItem: string; |
| 24 | +} |
| 25 | +type Tree = Array<Node>; |
| 26 | +export const TableOfContent = component$<TableOfContentProps>((props) => { |
| 27 | + const inifiniteStopper = props.headings.map((heading) => { |
| 28 | + return { text: heading.text, id: heading.id, level: heading.level }; |
| 29 | + }); |
| 30 | + const itemIds = props.headings.map((item) => item.id); |
| 31 | + const activeHeading = useActiveItem(itemIds); |
| 32 | + const tree = getTree(inifiniteStopper); |
| 33 | + return <RecursiveJSX tree={tree[0]} activeItem={activeHeading.value} />; |
| 34 | +}); |
| 35 | + |
| 36 | +function deltaToStrg( |
| 37 | + currNode: Node, |
| 38 | + nextNode: Node, |
| 39 | +): 'same level' | 'down one level' | 'up one level' { |
| 40 | + const delta = currNode.level - nextNode.level; |
| 41 | + if (delta === 1) { |
| 42 | + return 'up one level'; |
| 43 | + } |
| 44 | + if (delta === -1) { |
| 45 | + return 'down one level'; |
| 46 | + } |
| 47 | + |
| 48 | + if (delta === 0) { |
| 49 | + return 'same level'; |
| 50 | + } |
| 51 | + throw new Error( |
| 52 | + `bad headings: are not continous from: #${currNode.id} to #${nextNode.id}`, |
| 53 | + ); |
| 54 | +} |
| 55 | +function getTree(nodes: ContentHeading[]) { |
| 56 | + let currNode = nodes[0] as Node; |
| 57 | + currNode.children = []; |
| 58 | + const tree = [currNode]; |
| 59 | + const childrenMap = new Map<number, Tree>(); |
| 60 | + childrenMap.set(currNode.level, currNode.children); |
| 61 | + for (let index = 1; index < nodes.length; index++) { |
| 62 | + const nextNode = nodes[index] as Node; |
| 63 | + nextNode.children = []; |
| 64 | + childrenMap.set(nextNode.level, nextNode.children); |
| 65 | + const deltaStrg = deltaToStrg(currNode, nextNode); |
| 66 | + switch (deltaStrg) { |
| 67 | + case 'up one level': { |
| 68 | + const grandParent = childrenMap.get(currNode.level - 2); |
| 69 | + grandParent?.push(nextNode); |
| 70 | + break; |
| 71 | + } |
| 72 | + case 'same level': { |
| 73 | + const parent = childrenMap.get(currNode.level - 1); |
| 74 | + parent?.push(nextNode); |
| 75 | + break; |
| 76 | + } |
| 77 | + case 'down one level': { |
| 78 | + currNode.children.push(nextNode); |
| 79 | + break; |
| 80 | + } |
| 81 | + default: |
| 82 | + break; |
| 83 | + } |
| 84 | + currNode = nextNode; |
| 85 | + } |
| 86 | + return tree; |
| 87 | +} |
| 88 | +type RecursiveJSXProps = { |
| 89 | + tree: Node; |
| 90 | + activeItem: string; |
| 91 | + limit?: number; |
| 92 | +}; |
| 93 | +const RecursiveJSX = component$<RecursiveJSXProps>(({ tree, activeItem, limit = 3 }) => { |
| 94 | + const currNode: Node = tree; |
| 95 | + return currNode?.children?.length && currNode.level < limit ? ( |
| 96 | + <ul class={cn('m-0 list-none', { 'pl-4': currNode.level !== 1 })}> |
| 97 | + {currNode.children.map((childNode) => { |
| 98 | + return ( |
| 99 | + <li key={currNode.id} class={cn('mt-0 list-none pt-2')}> |
| 100 | + <Anchor node={childNode} activeItem={activeItem} /> |
| 101 | + {childNode.children.length ? ( |
| 102 | + <> |
| 103 | + <RecursiveJSX tree={childNode} activeItem={activeItem} /> |
| 104 | + </> |
| 105 | + ) : null} |
| 106 | + </li> |
| 107 | + ); |
| 108 | + })} |
| 109 | + </ul> |
| 110 | + ) : null; |
| 111 | +}); |
| 112 | + |
29 | 113 | const useActiveItem = (itemIds: string[]) => {
|
30 |
| - const activeId = useSignal<string>(); |
| 114 | + const activeId = useSignal<string>(''); |
31 | 115 |
|
32 | 116 | useVisibleTask$(({ cleanup }) => {
|
33 | 117 | const observer = new IntersectionObserver(
|
@@ -60,45 +144,35 @@ const useActiveItem = (itemIds: string[]) => {
|
60 | 144 |
|
61 | 145 | return activeId;
|
62 | 146 | };
|
63 |
| - |
64 |
| -type TreeProps = QwikIntrinsicElements['ul'] & { |
65 |
| - headings: ContentHeading[]; |
66 |
| - level?: number; |
67 |
| - activeItem?: string; |
| 147 | +type AnchorThingProps = { |
| 148 | + node: Node; |
| 149 | + activeItem: string; |
68 | 150 | };
|
69 |
| - |
70 |
| -const Tree = component$<TreeProps>(({ headings, level = 1, activeItem }) => { |
71 |
| - return headings.length > 0 && level < 3 ? ( |
72 |
| - <ul class={cn('m-0 list-none', { 'pl-4': level !== 1 })}> |
73 |
| - {headings.map((heading) => { |
74 |
| - return ( |
75 |
| - <li key={heading.id} class={cn('mt-0 pt-2')}> |
76 |
| - <a |
77 |
| - href={`#${heading.id}`} |
78 |
| - onClick$={[ |
79 |
| - $(() => { |
80 |
| - const element = document.getElementById(heading.id); |
81 |
| - if (element) { |
82 |
| - const navbarHeight = 90; |
83 |
| - const elementPosition = |
84 |
| - element.getBoundingClientRect().top + window.scrollY - navbarHeight; |
85 |
| - window.scrollTo({ top: elementPosition, behavior: 'auto' }); |
86 |
| - } |
87 |
| - }), |
88 |
| - ]} |
89 |
| - class={cn( |
90 |
| - heading.level > 2 ? 'ml-4' : null, |
91 |
| - 'inline-block no-underline transition-colors hover:text-foreground', |
92 |
| - heading.id === `${activeItem}` |
93 |
| - ? 'font-medium text-foreground' |
94 |
| - : 'text-muted-foreground', |
95 |
| - )} |
96 |
| - > |
97 |
| - {heading.text} |
98 |
| - </a> |
99 |
| - </li> |
100 |
| - ); |
101 |
| - })} |
102 |
| - </ul> |
103 |
| - ) : null; |
| 151 | +export const Anchor = component$<AnchorThingProps>((props) => { |
| 152 | + const currNode = props.node; |
| 153 | + const activeItem = props.activeItem; |
| 154 | + const isActiveItem = currNode.id === `${activeItem}`; |
| 155 | + return ( |
| 156 | + <a |
| 157 | + href={`#${currNode.id}`} |
| 158 | + onClick$={[ |
| 159 | + $(() => { |
| 160 | + const element = document.getElementById(currNode.id); |
| 161 | + if (element) { |
| 162 | + const navbarHeight = 90; |
| 163 | + const elementPosition = |
| 164 | + element.getBoundingClientRect().top + window.scrollY - navbarHeight; |
| 165 | + window.scrollTo({ top: elementPosition, behavior: 'auto' }); |
| 166 | + } |
| 167 | + }), |
| 168 | + ]} |
| 169 | + class={cn( |
| 170 | + currNode.level > 2 ? 'ml-4' : null, |
| 171 | + 'inline-block no-underline transition-colors hover:text-foreground', |
| 172 | + isActiveItem ? 'font-medium text-foreground' : 'text-muted-foreground', |
| 173 | + )} |
| 174 | + > |
| 175 | + {currNode.text} |
| 176 | + </a> |
| 177 | + ); |
104 | 178 | });
|
0 commit comments