├── .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 | 
6 |
7 | ## changelog
8 |
9 | ### 2023/1/22
10 |
11 | - add "Homonyms". You can toggle it on settings panel
12 |
13 | 
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 | 
21 |
22 |
23 | ### 2022/12/1
24 |
25 | - hierarchy can now set the display level
26 |
27 | 
28 |
29 |
30 | ### 2022/11/30
31 |
32 | - hierarchy can now be sorted alphabetically and by edit time
33 |
34 | 
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 |
406 |
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 | }
--------------------------------------------------------------------------------