├── .gitignore ├── README.md ├── build.sh ├── image.png ├── images ├── Hierarchy demo.gif ├── SCR-20221201-oxq.png ├── SCR-20221201-oxu.png └── bb2.gif ├── package.json ├── src ├── breadcrumbs-block.tsx ├── hierarchy.tsx ├── index.ts ├── settings-panel.ts ├── settings.ts ├── style.css ├── type.d.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | extension.js 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roam Hierarchy 2 | 3 | Namespace pages now display with hierarchy 4 | 5 | ![](https://github.com/dive2Pro/roam-hierarchy/blob/main/images/bb2.gif) 6 | 7 | ## changelog 8 | 9 | ### 2023/1/22 10 | 11 | - add "Homonyms". You can toggle it on settings panel 12 | 13 | ![image](https://user-images.githubusercontent.com/23192045/213916167-45f8d91c-6eae-407b-9125-9f5aa14bf8f9.png) 14 | 15 | 16 | ### 2022/12/29 17 | 18 | - hierarchy can now update the titles of subhierarchy pages by updating the current page title 19 | 20 | ![al](https://user-images.githubusercontent.com/23192045/209928883-df044963-732b-4344-9bf4-c703fb8b0cb1.gif) 21 | 22 | 23 | ### 2022/12/1 24 | 25 | - hierarchy can now set the display level 26 | 27 | ![](https://github.com/dive2Pro/roam-hierarchy/blob/main/images/SCR-20221201-oxu.png) 28 | 29 | 30 | ### 2022/11/30 31 | 32 | - hierarchy can now be sorted alphabetically and by edit time 33 | 34 | ![](https://github.com/dive2Pro/roam-hierarchy/blob/main/images/SCR-20221201-oxq.png) 35 | 36 | 37 | - backlink in hierarchy can now be zoom in and zoom out (alt+click on bullet dot) 38 | 39 | 40 | 41 | ## FAQ 42 | 43 | - How to refresh data? 44 | 45 | close and reopen "Hierarchy" 46 | 47 | 48 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | npm install --save --legacy-peer-deps 2 | npm run build -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dive2Pro/roam-hierarchy/805ef59fd022088d5e4851d53b89dd8c46542133/image.png -------------------------------------------------------------------------------- /images/Hierarchy demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dive2Pro/roam-hierarchy/805ef59fd022088d5e4851d53b89dd8c46542133/images/Hierarchy demo.gif -------------------------------------------------------------------------------- /images/SCR-20221201-oxq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dive2Pro/roam-hierarchy/805ef59fd022088d5e4851d53b89dd8c46542133/images/SCR-20221201-oxq.png -------------------------------------------------------------------------------- /images/SCR-20221201-oxu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dive2Pro/roam-hierarchy/805ef59fd022088d5e4851d53b89dd8c46542133/images/SCR-20221201-oxu.png -------------------------------------------------------------------------------- /images/bb2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dive2Pro/roam-hierarchy/805ef59fd022088d5e4851d53b89dd8c46542133/images/bb2.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dive2pro/roam-hierarchy", 3 | "version": "0.0.1", 4 | "description": "extension for hierarchy", 5 | "main": "dist/extension.js", 6 | "scripts": { 7 | "build-web": "roamjs-scripts build", 8 | "build": "roamjs-scripts build --depot", 9 | "dev": "roamjs-scripts dev --depot" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/dive2Pro/roam-hierarchy.git" 14 | }, 15 | "keywords": [], 16 | "author": "hyc", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/dive2Pro/roam-hierarchy/issues" 20 | }, 21 | "homepage": "https://github.com/dive2Pro/roam-hierarchy#readme", 22 | "dependencies": { 23 | "roamjs-components": "^0.72.9" 24 | }, 25 | "devDependencies": { 26 | "@blueprintjs/core": "3.54.0", 27 | "@blueprintjs/select": "3.19.1", 28 | "@types/arrive": "^2.4.1", 29 | "@types/react": "16.14.2", 30 | "@types/react-dom": "16.9.10", 31 | "roamjs-scripts": "^0.21.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/breadcrumbs-block.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { Icon } from "@blueprintjs/core"; 3 | 4 | type ReversePullBlock = { 5 | ":block/uid": string; 6 | ":block/string": string; 7 | ":node/title": string; 8 | ":block/_children": ReversePullBlock[]; 9 | }; 10 | 11 | const getStrFromParentsOf = (blockUid: string) => { 12 | const result = window.roamAlphaAPI.pull( 13 | ` 14 | [ 15 | :block/uid 16 | :block/string 17 | :node/title 18 | {:block/_children ...} 19 | ] 20 | `, 21 | [":block/uid", `${blockUid}`] 22 | ) as unknown as ReversePullBlock; 23 | 24 | if (result) { 25 | let strs = []; 26 | let ary = result[":block/_children"]; 27 | while (ary && ary.length) { 28 | const block = ary[0]; 29 | strs.unshift(block); 30 | ary = block[":block/_children"]; 31 | } 32 | return strs; 33 | } 34 | return []; 35 | }; 36 | 37 | const BlockEdit = ({ uid }: { uid: string }) => { 38 | const ref = useRef(); 39 | useEffect(() => { 40 | window.roamAlphaAPI.ui.components.renderBlock({ uid, el: ref.current }); 41 | return () => {}; 42 | }, [uid]); 43 | return
; 44 | }; 45 | 46 | // @deprected 47 | export function OldBreadcrumbsBlock(props: { uid: string; showPage?: boolean }) { 48 | const [uid, setUid] = useState(props.uid); 49 | const [parents, setParents] = useState([]); 50 | useEffect(() => { 51 | const parentsBlocks = getStrFromParentsOf(uid); 52 | console.log(parentsBlocks); 53 | setParents(props.showPage ? parentsBlocks : parentsBlocks.slice(1)); 54 | }, [uid, props.showPage]); 55 | 56 | return ( 57 |
{ 60 | const target = e.target as HTMLDivElement; 61 | if (target.closest(".controls.rm-block__controls")) { 62 | const t = target 63 | .closest("div.rm-block-main") 64 | .querySelector("div.rm-block__input"); 65 | const tuid = t.id.split("").splice(-9).join(""); 66 | if (e.altKey) { 67 | e.preventDefault(); 68 | e.stopPropagation(); 69 | setUid(tuid); 70 | } 71 | setTimeout(() => { 72 | const portal = document 73 | .querySelector(".rm-bullet__tooltip") 74 | ?.closest(".bp3-portal"); 75 | portal?.parentElement?.removeChild(portal); 76 | }, 1000); 77 | return true; 78 | } 79 | }} 80 | onKeyDownCapture={(e) => { 81 | const target = e.target as HTMLTextAreaElement; 82 | if ( 83 | target.nodeName === "TEXTAREA" && 84 | target.id && 85 | target?.id.startsWith("block-input") && 86 | e.metaKey 87 | ) { 88 | const tuid = target.id.split("").splice(-9).join(""); 89 | let nBlock: ReversePullBlock; 90 | if (e.key === "," && parents.length > 1) { 91 | nBlock = parents[parents.length - 1]; 92 | if (nBlock) { 93 | setUid(nBlock[":block/uid"]); 94 | } 95 | } else if (e.key === ".") { 96 | setUid(tuid); 97 | } 98 | } 99 | }} 100 | > 101 | {parents.length ? ( 102 |
103 | {parents.map((block, index, ary) => { 104 | const s = block[":block/string"] || block[":node/title"]; 105 | return ( 106 | { 109 | setUid(block[":block/uid"]); 110 | }} 111 | > 112 | {s} 113 | {index < ary.length - 1 ? ( 114 | 119 | ) : null} 120 | 121 | ); 122 | })} 123 |
124 | ) : null} 125 | 126 | 127 |
128 | ); 129 | } 130 | 131 | export function BreadcrumbsBlock(props: { uid: string; showPage?: boolean }) { 132 | const ref = useRef() 133 | useEffect(() => { 134 | window.roamAlphaAPI.ui.components.renderBlock({ 135 | uid: props.uid, 136 | el: ref.current, 137 | // @ts-ignore 138 | "zoom-path?": true 139 | }) 140 | }, [props.uid]) 141 | 142 | return
143 | } 144 | -------------------------------------------------------------------------------- /src/hierarchy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect, useMemo, FC } from "react"; 2 | import "./style.css"; 3 | import { 4 | getCurrentPageUid, 5 | getPagesBaseonString, 6 | getPagesContainsString, 7 | onRouteChange, 8 | openPageByTitle, 9 | openPageByTitleOnSideBar, 10 | } from "./utils"; 11 | import ReactDOM from "react-dom"; 12 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 13 | import getBlockUidsReferencingPage from "roamjs-components/queries/getBlockUidsReferencingPage"; 14 | import getPageTitleByBlockUid from "roamjs-components/queries/getPageTitleByBlockUid"; 15 | import { 16 | Button, 17 | Icon, 18 | Popover, 19 | MenuItem, 20 | Menu, 21 | IconName, 22 | Tooltip, 23 | Slider, 24 | Label, 25 | Dialog, 26 | MultistepDialog, 27 | DialogStep, 28 | Classes, 29 | InputGroup, 30 | Divider, 31 | } from "@blueprintjs/core"; 32 | import { BreadcrumbsBlock } from "./breadcrumbs-block"; 33 | import { 34 | isHomonymsEnabled, 35 | readConfigFromUid, 36 | saveConfigByUid, 37 | } from "./settings"; 38 | import { isCollapsedByDefault } from "./settings-panel"; 39 | 40 | const delay = (ms = 10) => new Promise((resolve) => setTimeout(resolve, ms)); 41 | const InputPanel: React.FC<{ value: string; onChange: (v: string) => void }> = ( 42 | props 43 | ) => ( 44 |
45 | props.onChange(e.target.value)} 48 | /> 49 |
50 | ); 51 | 52 | function useTitleChangeStatus(props: { value?: string }) { 53 | const [state, setState] = useState({ 54 | isOpen: false, 55 | value: props.value, 56 | origin: "", 57 | }); 58 | 59 | const result = { 60 | state, 61 | onValueChange: (v: string) => setState((prev) => ({ ...prev, value: v })), 62 | setOriginTitle: (v: string) => setState((prev) => ({ ...prev, origin: v })), 63 | toggle: (next?: boolean) => { 64 | setState((prev) => ({ ...prev, isOpen: next ?? !prev.isOpen })); 65 | result.onValueChange(state.origin); 66 | }, 67 | }; 68 | return result; 69 | } 70 | 71 | const ConfirmPanel: FC = (props) => { 72 | return ( 73 |
74 |

Effected Pages

75 | 76 |

{props.children}

77 |
78 | ); 79 | }; 80 | 81 | const changePagesTitle = ( 82 | pages: [string, string, number][], 83 | title: string, 84 | value: string 85 | ) => { 86 | pages.forEach((page) => { 87 | window.roamAlphaAPI.updatePage({ 88 | page: { 89 | title: page[0].replace(title, value), 90 | uid: page[1], 91 | }, 92 | }); 93 | }); 94 | }; 95 | 96 | function TitleChangeDialog( 97 | props: ReturnType & { onSubmit: () => void } 98 | ) { 99 | const [effectedPages, setEffectedPages] = useState< 100 | [string, string, number][] 101 | >([]); 102 | useEffect(() => { 103 | const mount = async () => { 104 | const pages = await getPagesBaseonString(props.state.origin); 105 | const result = pages; 106 | // console.log(pages, result, props) 107 | setEffectedPages(result); 108 | }; 109 | mount(); 110 | }, [props.state.origin]); 111 | 112 | return ( 113 | props.toggle(false)} 116 | isOpen={props.state.isOpen} 117 | nextButtonProps={{ 118 | // disabled: this.state.value === undefined, 119 | disabled: props.state.origin === props.state.value, 120 | 121 | // tooltipContent: 122 | // this.state.value === undefined 123 | // ? "Select an option to continue" 124 | // : undefined, 125 | }} 126 | finalButtonProps={{ 127 | onClick: async () => { 128 | changePagesTitle( 129 | effectedPages, 130 | props.state.origin, 131 | props.state.value 132 | ); 133 | await delay(10); 134 | props.toggle(); 135 | props.onSubmit(); 136 | }, 137 | }} 138 | title={"Change Title"} 139 | > 140 | 147 | } 148 | title="Title Input" 149 | /> 150 | 154 | {effectedPages.map((page) => { 155 | return ( 156 |
157 | {page[0]} {" "} 158 | 159 | {page[0].replace(props.state.origin, props.state.value)} 160 | 161 |
162 | ); 163 | })} 164 | 165 | } 166 | title="Confirm" 167 | /> 168 |
169 | ); 170 | } 171 | 172 | function Hierarchy() { 173 | const [pages, setPages] = useState< 174 | { title: string; uid: string; time: number; level: number }[] 175 | >([]); 176 | const [sort, setSort] = useState({ 177 | sorts: [ 178 | { 179 | icon: "sort", 180 | text: "Priority", 181 | sort: () => 0, 182 | }, 183 | { 184 | icon: "sort-alphabetical", 185 | text: "Alphabetical asc", 186 | sort: (a, b) => { 187 | return a.title.localeCompare(b.title); 188 | }, 189 | }, 190 | { 191 | icon: "sort-alphabetical-desc", 192 | text: "Alphabetical desc", 193 | sort: (a, b) => { 194 | return b.title.localeCompare(a.title); 195 | }, 196 | }, 197 | { 198 | icon: "sort-asc", 199 | text: "Edit time asc", 200 | sort: (a, b) => { 201 | return a.time - b.time; 202 | }, 203 | }, 204 | { 205 | icon: "sort-desc", 206 | text: "Edit time desc", 207 | sort: (a, b) => { 208 | return b.time - a.time; 209 | }, 210 | }, 211 | ] as { 212 | icon: IconName; 213 | text: string; 214 | sort: ( 215 | a: { title: string; uid: string; time: number }, 216 | b: { title: string; uid: string; time: number } 217 | ) => number; 218 | }[], 219 | index: 0, 220 | }); 221 | const [level, setLevel] = useState({ 222 | min: 1, 223 | max: 1, 224 | current: 1, 225 | pageLevel: 1, 226 | }); 227 | const uidRef = useRef(""); 228 | let titleRef = useRef(""); 229 | const titleChangeStatus = useTitleChangeStatus({ value: titleRef.current }); 230 | 231 | async function getHierarchy() { 232 | const uid = await getCurrentPageUid(); 233 | uidRef.current = uid; 234 | const title = getPageTitleByPageUid(uid); 235 | let fulFillTile = title; 236 | if (title.includes("/")) { 237 | } else { 238 | fulFillTile = `${title}/`; 239 | } 240 | titleChangeStatus.setOriginTitle(title); 241 | titleChangeStatus.onValueChange(title); 242 | const pages = await getPagesBaseonString(fulFillTile); 243 | const lastIndex = title.lastIndexOf("/"); 244 | if (lastIndex > -1) titleRef.current = title.substring(0, lastIndex); 245 | const config = readConfigFromUid(uid); 246 | setSort((prev) => ({ ...prev, index: config.sort })); 247 | const pageInfos = pages 248 | .filter((info) => info[0] !== title) 249 | .map((info) => { 250 | const t = info[0].substring(fulFillTile.length + 1); 251 | return { 252 | title: info[0], 253 | uid: info[1], 254 | time: info[2], 255 | level: t.split("/").length, 256 | }; 257 | }); 258 | 259 | const maxLevel = Math.max( 260 | ...pageInfos.map((info) => { 261 | return info.level; 262 | }), 263 | 1 264 | ); 265 | setLevel((prev) => ({ 266 | ...prev, 267 | max: maxLevel, 268 | current: !!config.level ? config.level : maxLevel, 269 | pageLevel: title.split("/").length, 270 | })); 271 | 272 | setPages(pageInfos); 273 | } 274 | 275 | const content = pages.length ? ( 276 | pages 277 | .sort(sort.sorts[sort.index].sort) 278 | .map((page) => { 279 | return { 280 | ...page, 281 | title: page.title 282 | .split("/") 283 | .slice(0, level.current + level.pageLevel) 284 | .join("/"), 285 | }; 286 | }) 287 | .filter((page, index, arr) => { 288 | return index === arr.findIndex((item) => item.title === page.title); 289 | }) 290 | .map((info) => { 291 | return ; 292 | }) 293 | ) : titleRef.current ? ( 294 | 295 | 296 | 297 | ) : null; 298 | 299 | const caretTitleVm = useCaretTitle( 300 | (pages.length 301 | ? `${ 302 | (content as []).length !== pages.length 303 | ? (content as []).length + "/" 304 | : "" 305 | }${pages.length} ` 306 | : "") + "Hierarchy", 307 | isCollapsedByDefault() 308 | ); 309 | 310 | useEffect(() => { 311 | getHierarchy(); 312 | }, [caretTitleVm.open]); 313 | console.log(content, '---') 314 | if (!content) { 315 | return null; 316 | } 317 | return ( 318 |
319 |
320 | {caretTitleVm.Comp} 321 | { 324 | caretTitleVm.toggle(); 325 | caretTitleVm.toggle(); 326 | }} 327 | /> 328 | {!caretTitleVm.open ? null : ( 329 |
336 | 341 | { 345 | titleChangeStatus.toggle(); 346 | }} 347 | /> 348 | 349 | {sort.sorts.map((item, index) => { 350 | return ( 351 | { 356 | setSort((prev) => { 357 | return { 358 | ...prev, 359 | index, 360 | }; 361 | }); 362 | 363 | saveConfigByUid(uidRef.current, { 364 | level: level.current, 365 | sort: index, 366 | }); 367 | }} 368 | > 369 | ); 370 | })} 371 | 372 | {level.min === level.max ? null : ( 373 | 374 | 380 | setLevel((prev) => ({ ...prev, current: v })) 381 | } 382 | onRelease={(v) => { 383 | setLevel((prev) => ({ ...prev, current: v })); 384 | saveConfigByUid(uidRef.current, { 385 | level: v, 386 | sort: sort.index, 387 | }); 388 | }} 389 | > 390 | 391 | )} 392 | 393 | } 394 | > 395 |
407 | )} 408 |
409 | {!caretTitleVm.open ? null : ( 410 |
{content}
411 | )} 412 |
413 | ); 414 | } 415 | 416 | function Homonyms() { 417 | const [pages, setPages] = useState<{ title: string; uid: string }[]>([]); 418 | const uidRef = useRef(""); 419 | let titleRef = useRef(""); 420 | 421 | async function getHomonyms() { 422 | const uid = await getCurrentPageUid(); 423 | uidRef.current = uid; 424 | const title = getPageTitleByPageUid(uid); 425 | 426 | let fulFillTile = title; 427 | if (title.includes("/")) { 428 | fulFillTile = title.split("/").pop(); 429 | } else { 430 | } 431 | 432 | const pages = await getPagesContainsString(fulFillTile); 433 | 434 | const pageInfos = pages 435 | .filter((info) => info[0] !== title) 436 | .filter((info) => { 437 | return info[0].split("/").pop() === fulFillTile; 438 | }) 439 | .map((info) => { 440 | return { 441 | title: info[0], 442 | uid: info[1], 443 | time: info[2], 444 | }; 445 | }); 446 | 447 | setPages(pageInfos); 448 | } 449 | 450 | const content = pages.length ? ( 451 | pages 452 | .filter((page, index, arr) => { 453 | return index === arr.findIndex((item) => item.title === page.title); 454 | }) 455 | .map((info) => { 456 | return ; 457 | }) 458 | ) : titleRef.current ? ( 459 | 460 | 461 | 462 | ) : null; 463 | 464 | const caretTitleVm = useCaretTitle( 465 | (pages.length 466 | ? `${ 467 | (content as []).length !== pages.length 468 | ? (content as []).length + "/" 469 | : "" 470 | }${pages.length} ` 471 | : "") + "Homonyms" 472 | ); 473 | 474 | useEffect(() => { 475 | if (caretTitleVm.open) { 476 | getHomonyms(); 477 | } 478 | }, [caretTitleVm.open]); 479 | if (!content) { 480 | return null; 481 | } 482 | return ( 483 |
484 |
485 | {caretTitleVm.Comp} 486 |
487 | {!caretTitleVm.open ? null : ( 488 |
{content}
489 | )} 490 |
491 | ); 492 | } 493 | 494 | const useCaretTitle = (children?: any, initOpen = true) => { 495 | const [open, setOpen] = useState(initOpen); 496 | 497 | return { 498 | open, 499 | Comp: ( 500 | 501 | {children} 502 | 503 | ), 504 | toggle: () => setOpen((prev) => !prev), 505 | }; 506 | }; 507 | 508 | function CaretTitle(props: { 509 | open: boolean; 510 | onChange: (v: boolean) => void; 511 | children: any; 512 | }) { 513 | return ( 514 |
props.onChange(!props.open)}> 515 | 520 | 525 | {props.children} 526 | 527 |
528 | ); 529 | } 530 | 531 | function PageLink(props: { title: string; name: string; className?: string }) { 532 | return ( 533 | { 535 | e.preventDefault(); 536 | e.stopPropagation(); 537 | if (e.shiftKey) { 538 | openPageByTitleOnSideBar(props.title); 539 | return; 540 | } 541 | return openPageByTitle(props.title); 542 | }} 543 | className={props.className} 544 | style={{ cursor: "pointer" }} 545 | > 546 | [[ 547 | {props.name} 548 | ]] 549 | 550 | ); 551 | } 552 | 553 | function SpansLink(props: { spans: string[] }) { 554 | return ( 555 | <> 556 | {props.spans.map((s, index) => { 557 | const pageTitle = props.spans.slice(0, index + 1).join("/"); 558 | return ( 559 | <> 560 | 561 | {index < props.spans.length - 1 ? / : null} 562 | 563 | ); 564 | })} 565 | 566 | ); 567 | } 568 | 569 | const NoChildLink = (props: { children: any }) => { 570 | return ( 571 |
575 | 581 | 586 | {props.children} 587 | 588 |
589 | ); 590 | }; 591 | 592 | function HierarchyLink(props: { info: { title: string; uid: string } }) { 593 | const { title } = props.info; 594 | const spans = title.split("/"); 595 | // 找到所有层级比当前页面层级低的 references 596 | const caretTitleVm = useCaretTitle(, false); 597 | const referencingBlocks = useMemo( 598 | () => getBlockUidsReferencingPage(title), 599 | [title] 600 | ); 601 | return ( 602 |
603 | {referencingBlocks.length ? ( 604 | caretTitleVm.Comp 605 | ) : ( 606 | 607 | 608 | 609 | )} 610 | {caretTitleVm.open ? ( 611 |
612 | 613 |
614 | ) : null} 615 |
616 | ); 617 | } 618 | 619 | function groupBySamePage(uids: string[]) { 620 | const group: Record< 621 | string, 622 | { 623 | title: string; 624 | uids: string[]; 625 | } 626 | > = {}; 627 | uids.forEach((uid) => { 628 | const pageTitle = getPageTitleByBlockUid(uid); 629 | if (group[pageTitle]) { 630 | group[pageTitle].uids.push(uid); 631 | } else { 632 | group[pageTitle] = { 633 | title: pageTitle, 634 | uids: [uid], 635 | }; 636 | } 637 | }); 638 | return Object.values(group); 639 | } 640 | 641 | function Mention({ info }: { info: { title: string; uids: string[] } }) { 642 | const [isOpen, setOpen] = useState(true); 643 | return ( 644 |
645 |
646 | setOpen(!isOpen)} 650 | > 651 | 656 |
657 | {isOpen ? ( 658 |
659 | {info.uids.map((uid) => { 660 | return ( 661 |
662 | 663 |
664 | ); 665 | })} 666 |
667 | ) : null} 668 |
669 | ); 670 | } 671 | function HierarchyMentions(props: { blocks: string[] }) { 672 | return ( 673 | <> 674 | {groupBySamePage(props.blocks).map((info) => ( 675 | 676 | ))} 677 | 678 | ); 679 | } 680 | 681 | function App() { 682 | return ( 683 | <> 684 | 685 | {isHomonymsEnabled() ? : null} 686 | 687 | ); 688 | } 689 | export function renderApp() { 690 | const el = document.querySelector(".rm-hierarchy-el"); 691 | if (el) ReactDOM.render(, el); 692 | } 693 | export function hierarchyInit() { 694 | let unSub = () => {}; 695 | const init = async () => { 696 | if (document.querySelector(".roam-log-page")) { 697 | return; 698 | } 699 | await delay(500); 700 | const el = document.createElement("div"); 701 | el.className = "rm-hierarchy-el"; 702 | const parent = document 703 | .querySelector(".roam-article") 704 | .children[1].querySelector(".rm-reference-main"); 705 | parent.insertBefore(el, parent.childNodes[0]); 706 | console.log(el, parent, ' === hierarchy ') 707 | ReactDOM.render(, el); 708 | unSub = () => { 709 | ReactDOM.unmountComponentAtNode(el); 710 | parent.removeChild(el); 711 | unSub = () => {}; 712 | }; 713 | }; 714 | onRouteChange(() => { 715 | unSub(); 716 | init(); 717 | }); 718 | init(); 719 | const removeStyle = addStyle(); 720 | return () => { 721 | unSub(); 722 | removeStyle(); 723 | }; 724 | } 725 | 726 | function addStyle() { 727 | const style = document.createElement("style"); 728 | document.head.appendChild(style); 729 | style.innerHTML = ` 730 | .rm-hierarchy-el { 731 | 732 | } 733 | .rm-hierarchy { 734 | -webkit-user-select: none; 735 | -khtml-user-select: none; 736 | -moz-user-select: none; 737 | -o-user-select: none; 738 | user-select: none; 739 | margin: -4px -4px 10px -16px; 740 | } 741 | .rm-hierarchy .bp3-menu-item { 742 | outline: none; 743 | } 744 | 745 | .rm-hierarchy .bp3-menu-item a{ 746 | text-decoration: none; 747 | } 748 | 749 | .rm-mention .caret-title .bp3-icon-caret-right { 750 | opacity: 0; 751 | } 752 | .rm-mention:hover .caret-title .bp3-icon { 753 | opacity: 1; 754 | } 755 | 756 | .rm-hierarchy > div:first-child .caret-title:first-child .rm-caret{ 757 | top: 2px; 758 | opacity: 0; 759 | } 760 | .rm-hierarchy > div:first-child .caret-title:first-child:hover .rm-caret{ 761 | opacity: 1; 762 | } 763 | .rm-mention { 764 | margin-bottom: 5px; 765 | } 766 | 767 | .rm-hierarchy .caret-title .bp3-icon-caret-down{ 768 | opacity: 0; 769 | } 770 | .rm-hierarchy .caret-title .bp3-icon-caret-down:hover{ 771 | opacity: 1; 772 | } 773 | 774 | 775 | .block-breadcrumbs { 776 | flex-wrap: wrap; 777 | display: flex; 778 | flex-direction: row; 779 | padding: 5px 5px 5px 8px; 780 | } 781 | 782 | .block-breadcrumbs span.block-br-item { 783 | white-space: unset; 784 | align-items: center; 785 | display: inline-flex; 786 | cursor: pointer; 787 | font-size: 12px; 788 | } 789 | 790 | .page .roam-block { 791 | position: relative; 792 | } 793 | 794 | .page .rm-block-separator { 795 | min-width: 0px; 796 | max-width: 10px; 797 | flex: 0 0 0px; 798 | } 799 | 800 | .rm-hierarchy .levels { 801 | display: flex; 802 | } 803 | .rm-hierarchy .levels .bp3-slider { 804 | width: 100px; 805 | margin-left: 10px; 806 | } 807 | `; 808 | return () => { 809 | document.head.removeChild(style); 810 | }; 811 | } 812 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { hierarchyInit } from "./hierarchy"; 2 | import { initAPI } from "./settings"; 3 | 4 | let initial = (extensionAPI: RoamExtensionAPI) => { 5 | initAPI(extensionAPI); 6 | const hierarchyUnload = hierarchyInit(); 7 | return () => { 8 | hierarchyUnload(); 9 | }; 10 | }; 11 | 12 | let initialed = () => {}; 13 | 14 | function onload({ extensionAPI }: { extensionAPI: RoamExtensionAPI }) { 15 | initialed = initial(extensionAPI); 16 | } 17 | 18 | function onunload() { 19 | initialed(); 20 | } 21 | 22 | export default { 23 | onload, 24 | onunload, 25 | }; 26 | -------------------------------------------------------------------------------- /src/settings-panel.ts: -------------------------------------------------------------------------------- 1 | import { renderApp } from "./hierarchy"; 2 | 3 | let API: RoamExtensionAPI; 4 | export const initPanel = (api: RoamExtensionAPI) => { 5 | API = api; 6 | api.settings.panel.create({ 7 | tabTitle: "Roam Hierarchy", 8 | settings: [ 9 | { 10 | id: "brackets", 11 | name: "Toggle brackets", 12 | description: "", 13 | action: { 14 | type: "switch", 15 | onChange: (e: any) => { 16 | const el = document.querySelector( 17 | ".rm-reference-main" 18 | ) as HTMLElement; 19 | !e.target.checked 20 | ? el.classList.add("no-brackets") 21 | : el.classList.remove("no-brackets"); 22 | }, 23 | }, 24 | }, 25 | { 26 | id: "collapsed", 27 | name: "Collapsed by default", 28 | description: "", 29 | action: { 30 | type: "switch", 31 | }, 32 | }, 33 | { 34 | id: "homonyms", 35 | name: "Toggle Homonyms", 36 | description: "", 37 | action: { 38 | type: "switch", 39 | onChange: (e: any) => { 40 | setTimeout(renderApp); 41 | }, 42 | }, 43 | }, 44 | ], 45 | }); 46 | const v = api.settings.get("brackets"); 47 | api.settings.set("brackets", v ?? true); 48 | const v1 = api.settings.get("brackets"); 49 | api.settings.set("homonyms", v1 ?? true); 50 | }; 51 | 52 | 53 | export function isCollapsedByDefault() { 54 | return !API.settings.get("collapsed"); 55 | } -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { initPanel } from "./settings-panel"; 2 | 3 | let extensionAPI: RoamExtensionAPI; 4 | 5 | export const initAPI = (api: RoamExtensionAPI) => { 6 | extensionAPI = api; 7 | initPanel(api); 8 | }; 9 | 10 | const CONFIG_PREFIX = "config-"; 11 | 12 | type Config = { 13 | sort: number; 14 | level: number; 15 | }; 16 | export const readConfigFromUid = (uid: string) => { 17 | const key = CONFIG_PREFIX + uid; 18 | try { 19 | const jsonStr = extensionAPI.settings.get(key) as string; 20 | const json = JSON.parse(jsonStr); 21 | return (json || { 22 | sort: 0, 23 | level: 0, 24 | }) as Config; 25 | } catch (e) { 26 | return { 27 | sort: 0, 28 | level: 0, 29 | }; 30 | } 31 | }; 32 | 33 | export const saveConfigByUid = (uid: string, config: Config) => { 34 | const key = CONFIG_PREFIX + uid; 35 | extensionAPI.settings.set(key, JSON.stringify(config)); 36 | }; 37 | 38 | export const isHomonymsEnabled = () => { 39 | return !!extensionAPI.settings.get("homonyms"); 40 | } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .rm-reference-main.no-brackets .rm-page-ref__brackets{ 2 | display: none; 3 | } -------------------------------------------------------------------------------- /src/type.d.ts: -------------------------------------------------------------------------------- 1 | type RoamExtensionAPI = { 2 | settings: { 3 | get: (k: string) => unknown; 4 | getAll: () => Record; 5 | panel: { 6 | create: (c: PanelConfig) => void; 7 | }; 8 | set: (k: string, v: unknown) => Promise; 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 2 | 3 | export const generateId = () => { 4 | return window.roamAlphaAPI.util.generateUID(); 5 | }; 6 | 7 | const createPageByTitle = async (title: string) => { 8 | try { 9 | await window.roamAlphaAPI.createPage({ page: { title: title } }); 10 | } catch (e) {} 11 | }; 12 | 13 | export const createOrGetPageByName = async (title: string): Promise => { 14 | await createPageByTitle(title); 15 | return getPageUidByPageTitle(title); 16 | }; 17 | 18 | export const onRouteChange = (cb: () => void) => { 19 | const onhashchange = window.onhashchange?.bind(window); 20 | 21 | window.onhashchange = (evt) => { 22 | onhashchange?.call(window, evt); 23 | setTimeout(() => { 24 | cb(); 25 | }, 200); 26 | }; 27 | return () => { 28 | window.onhashchange = onhashchange; 29 | }; 30 | }; 31 | 32 | export const getPagesBaseonString = async (str: string) => { 33 | const result = await window.roamAlphaAPI.q(` 34 | [ 35 | :find ?title:name ?title:uid ?time:date 36 | :where 37 | [?page :node/title ?title:name] 38 | [?page :block/uid ?title:uid] 39 | [?page :edit/time ?time:date] 40 | [(clojure.string/starts-with? ?title:name "${str}")]] 41 | `) as [string, string, number][]; 42 | return result; 43 | }; 44 | 45 | export const getPagesContainsString = async (str: string) => { 46 | const result = await window.roamAlphaAPI.q(` 47 | [ 48 | :find ?title:name ?title:uid 49 | :where 50 | [?page :node/title ?title:name] 51 | [?page :block/uid ?title:uid] 52 | [(clojure.string/includes? ?title:name "${str}")]] 53 | `); 54 | return result as [string, string, number][]; 55 | } 56 | 57 | export const getCurrentPageUid = async () => { 58 | const blockOrPageUid = 59 | await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid(); 60 | let pageUid = (await window.roamAlphaAPI.q( 61 | `[ 62 | :find [?e] 63 | :in $ ?id 64 | :where 65 | [?b :block/uid ?id] 66 | [?b :block/page ?p] 67 | [?p :block/uid ?e] 68 | ] 69 | `, 70 | blockOrPageUid 71 | )?.[0]) as unknown as string; 72 | 73 | return pageUid || blockOrPageUid; 74 | }; 75 | export async function openPageByTitleOnSideBar(title: string) { 76 | const uid = await createOrGetPageByName(title); 77 | window.roamAlphaAPI.ui.rightSidebar.addWindow({ 78 | window: { 79 | "block-uid": uid, 80 | type: "outline", 81 | }, 82 | }); 83 | } 84 | 85 | export async function openPageByTitle(title: string) { 86 | await createPageByTitle(title); 87 | window.roamAlphaAPI.ui.mainWindow.openPage({ 88 | page: { title: title }, 89 | }); 90 | } 91 | 92 | export const getBlockTextByUid = (uid: string) => { 93 | const [result] = window.roamAlphaAPI.q( 94 | `[:find [?e] :where [?b :block/uid "${uid}"] [?b :block/string ?e]]` 95 | ); 96 | return (result as any as string) || ""; 97 | }; 98 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/", 4 | "noImplicitAny": true, 5 | "allowSyntheticDefaultImports": true, 6 | "moduleResolution": "node", 7 | "module": "esnext", 8 | "target": "es2019", 9 | "esModuleInterop": true, 10 | "allowJs": true, 11 | "jsx": "preserve", 12 | "lib": ["dom", "dom.iterable", "esnext"], 13 | "skipLibCheck": true, 14 | "strict": false, 15 | "forceConsistentCasingInFileNames": true, 16 | "noEmit": true, 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "importHelpers": true, 20 | "experimentalDecorators": true, 21 | }, 22 | "include": [ 23 | "src", 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ], 28 | "files": [ 29 | "node_modules/roamjs-components/types/index.d.ts" 30 | ] 31 | } --------------------------------------------------------------------------------