microsoft/qdk
Publicmirrored from https://github.com/microsoft/qdkAvailable
source/vscode/src/webview/docview.tsx
452lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | import { Markdown } from "qsharp-lang/ux"; |
| 5 | import { useEffect, useState } from "preact/hooks"; |
| 6 | |
| 7 | export interface IDocFile { |
| 8 | filename: string; |
| 9 | metadata: string; |
| 10 | contents: string; |
| 11 | } |
| 12 | |
| 13 | interface ItemDocs { |
| 14 | pkg: string; |
| 15 | namespace: string; |
| 16 | member: string; |
| 17 | content: string; |
| 18 | } |
| 19 | |
| 20 | interface PageContents { |
| 21 | [name: string]: { |
| 22 | onclick: (evt: Event) => void; |
| 23 | content: string; |
| 24 | anchor: string; |
| 25 | }; |
| 26 | } |
| 27 | |
| 28 | function GetPageContents( |
| 29 | currPath: string, |
| 30 | docs: ItemDocs[], |
| 31 | setPath: any, |
| 32 | ): PageContents { |
| 33 | const contents: PageContents = {}; |
| 34 | |
| 35 | if (currPath === "") { |
| 36 | // Collect the set of all packages |
| 37 | docs.forEach((doc) => { |
| 38 | if (!(doc.pkg in contents)) { |
| 39 | contents[doc.pkg] = { |
| 40 | onclick: () => setPath(doc.pkg), |
| 41 | content: "", |
| 42 | anchor: "", |
| 43 | }; |
| 44 | } |
| 45 | }); |
| 46 | } else if (currPath.indexOf("/") === -1) { |
| 47 | // Render the list of namespaces in the current package |
| 48 | docs.forEach((doc) => { |
| 49 | if (doc.pkg === currPath) { |
| 50 | if (!(doc.namespace in contents)) { |
| 51 | contents[doc.namespace] = { |
| 52 | onclick: () => setPath(`${currPath}/${doc.namespace}`), |
| 53 | content: "", |
| 54 | anchor: "", |
| 55 | }; |
| 56 | } |
| 57 | } |
| 58 | }); |
| 59 | } else { |
| 60 | // Render the list of members in the current namespace |
| 61 | docs.forEach((doc) => { |
| 62 | if ( |
| 63 | doc.pkg === currPath.split("/")[0] && |
| 64 | doc.namespace === currPath.split("/")[1] |
| 65 | ) { |
| 66 | contents[doc.member] = { |
| 67 | onclick: (evt: Event) => { |
| 68 | evt.preventDefault(); |
| 69 | scrollToElement(doc.member); |
| 70 | }, |
| 71 | content: doc.content, |
| 72 | anchor: doc.member, |
| 73 | }; |
| 74 | } |
| 75 | }); |
| 76 | } |
| 77 | return contents; |
| 78 | } |
| 79 | |
| 80 | function scrollToElement(id: string) { |
| 81 | const elem = id ? document.getElementById(id) : null; |
| 82 | if (!elem) { |
| 83 | window.scrollTo({ |
| 84 | top: 0, |
| 85 | behavior: "instant", |
| 86 | }); |
| 87 | } else { |
| 88 | const yOffset = -64; // Negative value to offset from the top |
| 89 | const yPosition = |
| 90 | elem.getBoundingClientRect().top + window.scrollY + yOffset; |
| 91 | |
| 92 | window.scrollTo({ |
| 93 | top: yPosition, |
| 94 | behavior: "instant", |
| 95 | }); |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | interface SearchResult { |
| 100 | rank: number; |
| 101 | anchor: string; |
| 102 | header: string; |
| 103 | summary: string; |
| 104 | } |
| 105 | |
| 106 | function getSearchResults( |
| 107 | searchText: string, |
| 108 | docs: ItemDocs[], |
| 109 | ): SearchResult[] { |
| 110 | const results: SearchResult[] = []; |
| 111 | |
| 112 | // Search on member name first, then on namespace name, then on package name, then on content |
| 113 | // Prefer earlier matches (e.g. "X" should match "X" before "CX") |
| 114 | |
| 115 | // RegExp groups |
| 116 | // 1. is the first header (e.g. 'Foo operation'), group |
| 117 | // 2. Is any text after the first header that precedes the next header |
| 118 | // 3. Ignore |
| 119 | // 4. Is the summary (if present) |
| 120 | const summaryRe = |
| 121 | /^(?:# )(.*?\n)\s*(.*?)(?=(##+)|$)(## Summary\r?\n.*?(?=(##+)|$))?/s; |
| 122 | |
| 123 | docs.forEach((doc) => { |
| 124 | const reMatch = doc.content.match(summaryRe); |
| 125 | const summary = (reMatch?.[2] ?? "") + (reMatch?.[4] ?? ""); |
| 126 | const header = doc.content.match(summaryRe)?.[1] ?? doc.member; |
| 127 | |
| 128 | const lowerText = searchText.toLowerCase(); |
| 129 | let rank = doc.member.toLowerCase().indexOf(lowerText) + 1; |
| 130 | if (rank) { |
| 131 | // Matches on larger parts of the name are more important |
| 132 | rank += doc.member.length / 100; |
| 133 | } |
| 134 | |
| 135 | if (!rank) { |
| 136 | rank = (doc.namespace.toLowerCase().indexOf(lowerText) + 1) * 100; |
| 137 | if (rank) { |
| 138 | rank += doc.namespace.length; |
| 139 | } |
| 140 | } |
| 141 | if (!rank) { |
| 142 | rank = (doc.pkg.toLowerCase().indexOf(lowerText) + 1) * 1000; |
| 143 | } |
| 144 | if (!rank) { |
| 145 | rank = (doc.content.toLowerCase().indexOf(lowerText) + 1) * 10000; |
| 146 | } |
| 147 | |
| 148 | if (rank) { |
| 149 | results.push({ |
| 150 | rank, |
| 151 | anchor: `${doc.pkg}/${doc.namespace}/${doc.member}`, |
| 152 | header, |
| 153 | summary, |
| 154 | }); |
| 155 | return; |
| 156 | } |
| 157 | }); |
| 158 | |
| 159 | return results.sort((a, b) => a.rank - b.rank); |
| 160 | } |
| 161 | |
| 162 | function DocsPage(props: { fragmentsToRender: ItemDocs[] }) { |
| 163 | // currPath is of the format: "<pkg>/<namespace>/<member>", e.g. |
| 164 | // "Std/Canon/CCNOT" or "Std/Microsoft.Quantum.Diagnostics/AssertMeasurementEqual" or |
| 165 | // "Unsigned/Main/GetInt" or "Main/Particle/Particle". When at the top level, currPath is "". |
| 166 | |
| 167 | const [currPath, setPath] = useState(""); |
| 168 | const [searchText, setSearchText] = useState(""); |
| 169 | |
| 170 | useEffect(() => { |
| 171 | // Update the xref links to navigate to the correct member |
| 172 | document.querySelectorAll('a[href^="xref:"]').forEach((a) => { |
| 173 | const anchor = a as HTMLAnchorElement; |
| 174 | const xref = "xref:"; |
| 175 | let link = anchor.href.slice(xref.length); |
| 176 | link = link.replaceAll(".", "/"); |
| 177 | // Just for Qdk links, we want to strip out the leading "Qdk." |
| 178 | if (link.startsWith("Qdk/")) { |
| 179 | link = link.slice(4); |
| 180 | } |
| 181 | a.addEventListener("click", (e) => { |
| 182 | e.preventDefault(); |
| 183 | setPath(link); |
| 184 | }); |
| 185 | }); |
| 186 | |
| 187 | // If the member is navigated to, scroll to it after rendering |
| 188 | const member = currPath.split("/")[2]; |
| 189 | scrollToElement(member); |
| 190 | }, [currPath]); |
| 191 | |
| 192 | // Skip processing contents if searching |
| 193 | const contents = |
| 194 | searchText === "" |
| 195 | ? GetPageContents(currPath, props.fragmentsToRender, setPath) |
| 196 | : {}; |
| 197 | |
| 198 | const searchResults = |
| 199 | searchText === "" |
| 200 | ? [] |
| 201 | : getSearchResults(searchText, props.fragmentsToRender); |
| 202 | |
| 203 | // Used to bold the text links when hovering |
| 204 | function overLi(e: MouseEvent) { |
| 205 | (e.target as HTMLElement).style.fontWeight = "600"; |
| 206 | (e.target as HTMLElement).style.textDecoration = "underline"; |
| 207 | (e.target as HTMLElement).style.cursor = "pointer"; |
| 208 | } |
| 209 | |
| 210 | function outLi(e: MouseEvent) { |
| 211 | (e.target as HTMLElement).style.fontWeight = "400"; |
| 212 | (e.target as HTMLElement).style.textDecoration = "none"; |
| 213 | (e.target as HTMLElement).style.cursor = "default"; |
| 214 | } |
| 215 | |
| 216 | // Whenever the breadcrumbs are clicked, go up one level |
| 217 | function onPathClick() { |
| 218 | setSearchText(""); |
| 219 | if (currPath) { |
| 220 | const parts = currPath.split("/"); |
| 221 | parts.pop(); |
| 222 | setPath(parts.join("/")); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // Handle the user focusing or updating the search box |
| 227 | function onSearchFocus(e: FocusEvent) { |
| 228 | e.preventDefault(); |
| 229 | if (e.target) { |
| 230 | const currText = (e.target as HTMLInputElement).value; |
| 231 | if (currText) setPath(""); |
| 232 | setSearchText(currText); |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | function onSearchInput(e: InputEvent) { |
| 237 | e.preventDefault(); |
| 238 | if (e.target) { |
| 239 | setPath(""); |
| 240 | setSearchText((e.target as HTMLInputElement).value); |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | function searchResultClicked(anchor: string) { |
| 245 | setSearchText(""); |
| 246 | setPath(anchor); |
| 247 | } |
| 248 | |
| 249 | return ( |
| 250 | <div |
| 251 | class="qs-docsPage" |
| 252 | style="width: 100%; padding-bottom: 1em; position: relative; background-color: var(--main-background); color: var(--main-color)" |
| 253 | > |
| 254 | <div |
| 255 | class="qs-docsHeader" |
| 256 | style="height: 3em; display: flex; justify-content: space-between; align-items: center; margin-top: 0px; padding-top: 1.5em; padding-bottom: 1em; position: fixed; top: 0; width: 95%; background-color: var(--main-background); z-index: 1;" |
| 257 | > |
| 258 | <div |
| 259 | onClick={() => { |
| 260 | setSearchText(""); |
| 261 | setPath(""); |
| 262 | }} |
| 263 | onMouseOver={overLi} |
| 264 | onMouseOut={outLi} |
| 265 | > |
| 266 | <svg |
| 267 | style="height: 2.25em; width: 2.25em; margin: 0.25em" |
| 268 | viewBox="0 0 100 100" |
| 269 | > |
| 270 | <g transform="translate(10,10)" fill="#888"> |
| 271 | <path d="M10,50 l40,-40 l40,40 Z"></path> |
| 272 | <path d="M20,49 l0,30 l20,0 l0,-30Z"></path> |
| 273 | <path d="M60,49 l0,30 l20,0 l0,-30Z"></path> |
| 274 | <path d="M39,49 l0,8 l24,0 l0,-30Z"></path> |
| 275 | <path d="M77,40 l0,-20 l-10,0 l0,20"></path> |
| 276 | </g> |
| 277 | </svg> |
| 278 | </div> |
| 279 | <div |
| 280 | style="flex-grow: 1; font-size: 1.4em; margin-left: 0.5em;" |
| 281 | onClick={onPathClick} |
| 282 | > |
| 283 | {currPath |
| 284 | ? currPath.replaceAll("/", " > ") |
| 285 | : searchText |
| 286 | ? "Search results" |
| 287 | : "Q# API documentation"} |
| 288 | </div> |
| 289 | <div> |
| 290 | <input |
| 291 | type="text" |
| 292 | placeholder="Search..." |
| 293 | onFocus={onSearchFocus} |
| 294 | onInput={onSearchInput} |
| 295 | /> |
| 296 | </div> |
| 297 | <div> |
| 298 | <svg |
| 299 | style="height: 2.25em; width: 2.25em; margin: 0.25em" |
| 300 | viewBox="0 0 100 100" |
| 301 | > |
| 302 | <g stroke="#888" fill="none" transform="translate(0,10)"> |
| 303 | <circle stroke-width="6" cx="40" cy="35" r="25"></circle> |
| 304 | <path stroke-width="8" d="M52,57 l20,30"></path> |
| 305 | </g> |
| 306 | </svg> |
| 307 | </div> |
| 308 | </div> |
| 309 | {searchText ? ( |
| 310 | <div |
| 311 | class="qs-searchResults" |
| 312 | style="margin: 2em; position: relative; top: 2em;" |
| 313 | > |
| 314 | {searchResults.map((result) => ( |
| 315 | <div> |
| 316 | <hr /> |
| 317 | <h1 |
| 318 | onMouseOver={overLi} |
| 319 | onMouseOut={outLi} |
| 320 | onClick={() => searchResultClicked(result.anchor)} |
| 321 | > |
| 322 | {result.header} |
| 323 | </h1> |
| 324 | <Markdown markdown={result.summary} /> |
| 325 | </div> |
| 326 | ))} |
| 327 | </div> |
| 328 | ) : ( |
| 329 | <div |
| 330 | class="qs-docsContent" |
| 331 | style="margin: 2em; position: relative; top: 2em;" |
| 332 | > |
| 333 | <div |
| 334 | class="qs-index" |
| 335 | style="background: var(--vscode-textCodeBlock-background); padding: 0.1em" |
| 336 | > |
| 337 | <p style="font-size: 1.1em; font-weight: 600; margin: 0.8em;"> |
| 338 | {currPath === "" |
| 339 | ? "Packages" |
| 340 | : currPath.indexOf("/") === -1 |
| 341 | ? "Namespaces" |
| 342 | : "Members"} |
| 343 | </p> |
| 344 | <ul> |
| 345 | {Object.keys(contents).map((key) => ( |
| 346 | <li |
| 347 | onClick={contents[key].onclick} |
| 348 | onMouseOver={overLi} |
| 349 | onMouseOut={outLi} |
| 350 | > |
| 351 | {key} |
| 352 | </li> |
| 353 | ))} |
| 354 | </ul> |
| 355 | </div> |
| 356 | |
| 357 | {Object.keys(contents).map((key) => |
| 358 | !contents[key].content ? null : ( |
| 359 | <div id={contents[key].anchor} style="margin-top: 12px;"> |
| 360 | <Markdown markdown={contents[key].content} /> |
| 361 | <hr /> |
| 362 | </div> |
| 363 | ), |
| 364 | )} |
| 365 | </div> |
| 366 | )} |
| 367 | </div> |
| 368 | ); |
| 369 | } |
| 370 | |
| 371 | export function DocumentationView(props: { |
| 372 | fragmentsToRender: IDocFile[]; |
| 373 | projectName: string; |
| 374 | }) { |
| 375 | const docs: ItemDocs[] = []; |
| 376 | |
| 377 | props.fragmentsToRender.forEach((doc) => { |
| 378 | if (!doc.metadata) { |
| 379 | return; |
| 380 | } |
| 381 | const pkg = doc.metadata.match(/^(?:qsharp\.package: )(.*)$/m)?.[1]; |
| 382 | const namespace = doc.metadata.match(/^(?:qsharp\.namespace: )(.*)$/m)?.[1]; |
| 383 | const member = doc.metadata.match(/^(?:qsharp\.name: )(.*)$/m)?.[1]; |
| 384 | |
| 385 | if (pkg && namespace && member) { |
| 386 | docs.push({ |
| 387 | pkg: |
| 388 | pkg === "__Core__" || pkg === "__Std__" |
| 389 | ? "Std" |
| 390 | : pkg === "__Main__" |
| 391 | ? props.projectName |
| 392 | : pkg, |
| 393 | // For some reason Std namespaces include the package name. Remove it. |
| 394 | namespace: namespace.startsWith("Std.") |
| 395 | ? namespace.slice(4) |
| 396 | : namespace, |
| 397 | member, |
| 398 | content: doc.contents, |
| 399 | }); |
| 400 | } |
| 401 | }); |
| 402 | |
| 403 | docs.sort((a, b) => { |
| 404 | if (a.pkg != b.pkg) { |
| 405 | // Sorted by __Main__, then reference packages, then __Std__ (which includes __Core__) |
| 406 | if (a.pkg === props.projectName || b.pkg === "Std") { |
| 407 | return -1; |
| 408 | } else if (b.pkg === props.projectName || a.pkg === "Std") { |
| 409 | return 1; |
| 410 | } else { |
| 411 | return a.pkg.localeCompare(b.pkg); |
| 412 | } |
| 413 | } else if (a.namespace != b.namespace) { |
| 414 | // Main namespace comes first and "Microsoft.Quantum.*" comes last |
| 415 | if ( |
| 416 | a.namespace === props.projectName || |
| 417 | b.namespace.startsWith("Microsoft.Quantum") |
| 418 | ) { |
| 419 | return -1; |
| 420 | } else if ( |
| 421 | b.namespace === props.projectName || |
| 422 | a.namespace.startsWith("Microsoft.Quantum") |
| 423 | ) { |
| 424 | return 1; |
| 425 | } else { |
| 426 | return a.namespace.localeCompare(b.namespace); |
| 427 | } |
| 428 | } else { |
| 429 | return a.member.localeCompare(b.member); |
| 430 | } |
| 431 | }); |
| 432 | |
| 433 | const style = document.createElement("style"); |
| 434 | style.textContent = ` |
| 435 | body { |
| 436 | background-color: var(--main-background); |
| 437 | margin: 0; |
| 438 | padding: 0; |
| 439 | } |
| 440 | |
| 441 | .markdown-body { |
| 442 | background-color: var(--main-background); |
| 443 | } |
| 444 | |
| 445 | .markdown-body code { |
| 446 | color: var(--main-color); |
| 447 | } |
| 448 | `; |
| 449 | document.head.appendChild(style); |
| 450 | |
| 451 | return <DocsPage fragmentsToRender={docs} />; |
| 452 | } |
| 453 | |