storyTableOfContents.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import {useLayoutEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import TextOverflow from 'sentry/components/textOverflow';
  4. import {space} from 'sentry/styles/space';
  5. type Entry = {
  6. ref: HTMLElement;
  7. title: string;
  8. };
  9. function toAlphaNumeric(str: string): string {
  10. return str.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
  11. }
  12. function getContentEntries(main: HTMLElement): Entry[] {
  13. const titles = main.querySelectorAll('h2, h3, h4, h5, h6');
  14. const entries: Entry[] = [];
  15. for (const entry of Array.from(titles ?? [])) {
  16. // Ensure each title has an id we can link to
  17. if (!entry.id) {
  18. entry.id = toAlphaNumeric(entry.textContent ?? '');
  19. }
  20. entries.push({
  21. title: entry.textContent ?? '',
  22. ref: entry as HTMLElement,
  23. });
  24. }
  25. return entries;
  26. }
  27. function useStoryIndex(): Entry[] {
  28. const [entries, setEntries] = useState<Entry[]>([]);
  29. useLayoutEffect(() => {
  30. const observer = new MutationObserver(_mutations => {
  31. const main = document.querySelector('main');
  32. if (main) {
  33. setEntries(getContentEntries(main));
  34. }
  35. });
  36. const main = document.querySelector('main');
  37. if (main) {
  38. observer.observe(document.body, {childList: true, subtree: true});
  39. }
  40. // Fire this immediately to ensure entries are set on pageload
  41. window.requestAnimationFrame(() => {
  42. setEntries(getContentEntries(document.querySelector('main')!));
  43. });
  44. return () => observer.disconnect();
  45. }, []);
  46. return entries;
  47. }
  48. type NestedEntry = {
  49. children: NestedEntry[];
  50. entry: Entry;
  51. };
  52. const TAGNAME_ORDER = ['H6', 'H5', 'H4', 'H3', 'H2'];
  53. function nestContentEntries(entries: Entry[]): NestedEntry[] {
  54. const nestedEntries: NestedEntry[] = [];
  55. if (entries.length <= 1) {
  56. return nestedEntries;
  57. }
  58. let parentEntry: NestedEntry | null = null;
  59. for (let i = 0; i < entries.length; i++) {
  60. const previousEntry = entries[i - 1];
  61. if (!previousEntry) {
  62. nestedEntries.push({
  63. entry: entries[i]!,
  64. children: [],
  65. });
  66. parentEntry = nestedEntries[nestedEntries.length - 1] ?? null;
  67. continue;
  68. }
  69. const entry = entries[i]!;
  70. const position = entry.ref.compareDocumentPosition(previousEntry.ref);
  71. const isAfter = !!(position & Node.DOCUMENT_POSITION_PRECEDING);
  72. const hierarchy =
  73. TAGNAME_ORDER.indexOf(entry.ref.tagName) <=
  74. TAGNAME_ORDER.indexOf(entries[i - 1]?.ref.tagName ?? '');
  75. if (isAfter && hierarchy && parentEntry) {
  76. const parent: NestedEntry = {
  77. entry,
  78. children: [],
  79. };
  80. parentEntry.children.push(parent);
  81. continue;
  82. }
  83. nestedEntries.push({
  84. entry,
  85. children: [],
  86. });
  87. parentEntry = nestedEntries[nestedEntries.length - 1] ?? null;
  88. }
  89. return nestedEntries;
  90. }
  91. export function StoryTableOfContents() {
  92. const entries = useStoryIndex();
  93. const nestedEntries = useMemo(() => nestContentEntries(entries), [entries]);
  94. return (
  95. <StoryIndexContainer>
  96. <StoryIndexTitle>Contents</StoryIndexTitle>
  97. <StoryIndexListContainer>
  98. <StoryIndexList>
  99. {nestedEntries.map(entry => (
  100. <StoryContentsList key={entry.entry.ref.id} entry={entry} />
  101. ))}
  102. </StoryIndexList>
  103. </StoryIndexListContainer>
  104. </StoryIndexContainer>
  105. );
  106. }
  107. function StoryContentsList({entry}: {entry: NestedEntry}) {
  108. return (
  109. <li>
  110. <a href={`#${entry.entry.ref.id}`}>
  111. <TextOverflow>{entry.entry.title}</TextOverflow>
  112. </a>
  113. {entry.children.length > 0 && (
  114. <StoryIndexList>
  115. {entry.children.map(child => (
  116. <StoryContentsList key={child.entry.ref.id} entry={child} />
  117. ))}
  118. </StoryIndexList>
  119. )}
  120. </li>
  121. );
  122. }
  123. const StoryIndexContainer = styled('div')`
  124. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  125. display: none;
  126. }
  127. `;
  128. const StoryIndexListContainer = styled('div')`
  129. > ul {
  130. padding-left: 0;
  131. margin-top: ${space(1)};
  132. }
  133. > ul > li {
  134. padding-left: 0;
  135. margin-top: ${space(1)};
  136. > a {
  137. margin-bottom: ${space(0.5)};
  138. }
  139. }
  140. `;
  141. const StoryIndexTitle = styled('div')`
  142. border-bottom: 1px solid ${p => p.theme.border};
  143. padding: ${space(0.5)} 0 ${space(1)} 0;
  144. margin-bottom: ${space(1)};
  145. `;
  146. const StoryIndexList = styled('ul')`
  147. list-style: none;
  148. padding-left: ${space(0.75)};
  149. margin: 0;
  150. width: 160px;
  151. li {
  152. &:hover {
  153. background: ${p => p.theme.backgroundSecondary};
  154. }
  155. a {
  156. padding: ${space(0.25)} 0;
  157. display: block;
  158. color: ${p => p.theme.textColor};
  159. text-decoration: none;
  160. }
  161. }
  162. `;