diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx new file mode 100644 index 000000000..d1acaee1e --- /dev/null +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -0,0 +1,400 @@ +import React, { useRef, useState } from "react"; +import ReactDOM from "react-dom"; +import { Collapse, Icon } from "@blueprintjs/core"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; +import extractRef from "roamjs-components/util/extractRef"; +import { getFormattedConfigTree } from "~/utils/discourseConfigRef"; +import type { + LeftSidebarConfig, + LeftSidebarPersonalSectionConfig, +} from "~/utils/getLeftSidebarSettings"; +import { createBlock } from "roamjs-components/writes"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; + +const parseReference = (text: string) => { + const extracted = extractRef(text); + if (extracted !== text) { + if (text.startsWith("((") && text.endsWith("))")) { + return { type: "block" as const, uid: extracted, display: text }; + } else if (text.startsWith("[[") && text.endsWith("]]")) { + return { type: "page" as const, title: extracted, display: extracted }; + } + } + return { type: "text" as const, display: text }; +}; + +const truncate = (s: string, max: number | undefined): string => { + if (!max || max <= 0) return s; + return s.length > max ? `${s.slice(0, max)}...` : s; +}; + +const openTarget = async ( + e: React.MouseEvent, + target: { kind: "block"; uid: string } | { kind: "page"; title: string }, +) => { + e.preventDefault(); + e.stopPropagation(); + + if (target.kind === "block") { + if (e.shiftKey) { + await openBlockInSidebar(target.uid); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ + block: { uid: target.uid }, + }); + return; + } + + const uid = getPageUidByPageTitle(target.title); + if (!uid) return; + if (e.shiftKey) { + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + window: { type: "outline", "block-uid": uid }, + }); + } else { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + } +}; + +type SectionChildrenProps = { + childrenNodes: { uid: string; text: string }[]; + truncateAt?: number; +}; +const SectionChildren = ({ + childrenNodes, + truncateAt, +}: SectionChildrenProps): JSX.Element | null => { + if (!childrenNodes?.length) return null; + return ( +
+ {childrenNodes.map((child) => { + const ref = parseReference(child.text); + const label = truncate(ref.display, truncateAt); + const onClick = (e: React.MouseEvent) => { + if (ref.type === "page") + return void openTarget(e, { kind: "page", title: ref.title }); + if (ref.type === "block") + return void openTarget(e, { kind: "block", uid: ref.uid }); + return void openTarget(e, { kind: "page", title: ref.display }); + }; + return ( +
+
+ {label} +
+
+ ); + })} +
+ ); +}; + +type PersonalSectionItemProps = { + section: LeftSidebarPersonalSectionConfig; +}; +const PersonalSectionItem = ({ + section, +}: PersonalSectionItemProps): JSX.Element => { + const ref = extractRef(section.text); + const blockText = getTextByBlockUid(ref); + if (section.isSimple) { + const ref = parseReference(section.text); + const onClick = (e: React.MouseEvent) => { + if (ref.type === "page") + return void openTarget(e, { kind: "page", title: ref.title }); + if (ref.type === "block") + return void openTarget(e, { kind: "block", uid: ref.uid }); + return void openTarget(e, { kind: "page", title: ref.display }); + }; + return ( +
+
+ {blockText || ref.display} +
+
+
+ ); + } + + const truncateAt = section.settings?.truncateResult.value; + const collapsable = !!section.settings?.collapsable.value; + const defaultOpen = !!section.settings?.open.value; + const [isOpen, setIsOpen] = useState(defaultOpen); + const alias = section.settings?.alias?.value; + const titleRef = parseReference(section.text); + const clickCountRef = useRef(0); + const clickTimerRef = useRef(null); + + const handleTitleClick = (e: React.MouseEvent) => { + if (e.shiftKey) { + if (titleRef.type === "page") + return void openTarget(e, { kind: "page", title: titleRef.title }); + if (titleRef.type === "block") + return void openTarget(e, { kind: "block", uid: titleRef.uid }); + return void openTarget(e, { kind: "page", title: titleRef.display }); + } + + clickCountRef.current += 1; + if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current); + + clickTimerRef.current = window.setTimeout(() => { + if (clickCountRef.current === 1) { + if (collapsable) { + setIsOpen((prev) => !prev); + if (section.settings?.open.uid) { + deleteBlock(section.settings.open.uid); + section.settings.open.uid = undefined; + section.settings.open.value = false; + } else { + if (section.settings?.uid) { + const newUid = window.roamAlphaAPI.util.generateUID(); + createBlock({ + parentUid: section.settings?.uid, + node: { + text: "Open?", + uid: newUid, + }, + }); + section.settings.open.uid = newUid; + section.settings.open.value = true; + } + } + } + } else { + if (titleRef.type === "page") + void window.roamAlphaAPI.ui.mainWindow.openPage({ + page: { uid: getPageUidByPageTitle(titleRef.title) }, + }); + else if (titleRef.type === "block") + void window.roamAlphaAPI.ui.mainWindow.openBlock({ + block: { uid: titleRef.uid }, + }); + } + clickCountRef.current = 0; + clickTimerRef.current = null; + }, 250); + }; + + return ( +
+
+
+ {alias || blockText || titleRef.display} + {collapsable && (section.children?.length || 0) > 0 && ( + + + + )} +
+
+
+ {collapsable ? ( + + + + ) : ( + + )} +
+ ); +}; + +type PersonalSectionsProps = { + config: LeftSidebarConfig["personal"]; +}; +const PersonalSections = ({ + config, +}: PersonalSectionsProps): JSX.Element | null => { + const sections = config.sections || []; + if (!sections.length) return null; + return ( +
+ {sections.map((s) => ( + + ))} +
+ ); +}; + +type GlobalSectionProps = { + config: LeftSidebarConfig["global"]; +}; +const GlobalSection = ({ config }: GlobalSectionProps): JSX.Element | null => { + const [isOpen, setIsOpen] = useState(!!config.open.value); + if (!config.children?.length) return null; + + return ( +
+
setIsOpen((p) => !p)} + style={{ + display: "flex", + alignItems: "center", + background: "transparent", + border: "none", + cursor: "pointer", + fontSize: 14, + fontWeight: 600, + padding: "4px 0", + width: "100%", + outline: "none", + transition: "color 0.2s ease-in", + }} + > +
+ Global + + + +
+
+
+ + + +
+ ); +}; + +const LeftSidebarView = (): JSX.Element | null => { + const config = getFormattedConfigTree().leftSidebar; + + return ( + <> + + + + + ); +}; + +export const mountLeftSidebarInto = (wrapper: HTMLElement): void => { + if (!wrapper) return; + + const existingStarred = wrapper.querySelector( + ".starred-pages", + ) as HTMLDivElement | null; + if (existingStarred && !existingStarred.id.includes("dg-left-sidebar-root")) { + try { + existingStarred.remove(); + } catch (e) { + console.warn( + "[DG][LeftSidebar] failed to remove default starred-pages", + e, + ); + } + } + + const id = "dg-left-sidebar-root"; + let root = wrapper.querySelector(`#${id}`) as HTMLDivElement | null; + if (!root) { + root = document.createElement("div"); + root.id = id; + root.className = "starred-pages"; + root.style.overflow = "scroll"; + root.onmousedown = (e) => e.stopPropagation(); + wrapper.appendChild(root); + } else { + root.className = "starred-pages"; + root.style.overflow = "scroll"; + } + + setTimeout(() => { + try { + ReactDOM.render(, root); + } catch (e) { + console.error("[DG][LeftSidebar] render error", e); + } + }, 500); +}; + +export default LeftSidebarView; diff --git a/apps/roam/src/components/settings/LeftSidebar.tsx b/apps/roam/src/components/settings/LeftSidebar.tsx new file mode 100644 index 000000000..e27186c22 --- /dev/null +++ b/apps/roam/src/components/settings/LeftSidebar.tsx @@ -0,0 +1,643 @@ +import { + FormattedConfigTree, + getFormattedConfigTree, +} from "~/utils/discourseConfigRef"; +import refreshConfigTree from "~/utils/refreshConfigTree"; +import React, { useCallback, useEffect, useState } from "react"; +import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel"; +import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import getAllPageNames from "roamjs-components/queries/getAllPageNames"; +import { Button, Dialog, Collapse } from "@blueprintjs/core"; +import createBlock from "roamjs-components/writes/createBlock"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import type { RoamBasicNode } from "roamjs-components/types"; +import NumberPanel from "roamjs-components/components/ConfigPanels/NumberPanel"; +import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel"; +import { ensureLeftSidebarReady } from "~/utils/ensureLeftSidebarStructure"; +import { + LeftSidebarGlobalSectionConfig, + LeftSidebarPersonalSectionConfig, +} from "~/utils/getLeftSidebarSettings"; +import { extractRef } from "roamjs-components/util"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; + +const SectionItem = ({ + section, + expandedChildLists, + convertToComplexSection, + setSettingsDialogSectionUid, + removeSection, + toggleChildrenList, +}: { + section: LeftSidebarPersonalSectionConfig; + expandedChildLists: Set; + convertToComplexSection: (section: LeftSidebarPersonalSectionConfig) => void; + setSettingsDialogSectionUid: (uid: string | null) => void; + removeSection: (section: LeftSidebarPersonalSectionConfig) => void; + toggleChildrenList: (uid: string) => void; +}) => { + const ref = extractRef(section.text); + const blockText = getTextByBlockUid(ref); + const alias = section.settings?.alias?.value; + + return ( +
+
+
+ {blockText || section.text} + {alias && Alias: ({alias})} +
+
+ {section.isSimple ? ( +
+
+ + {!section.isSimple && (section.children?.length || 0) > 0 && ( +
+
+ +
+ +
+ {(section.children || []).map((child) => ( +
+ {child.text} +
+ ))} +
+
+
+ )} +
+ ); +}; + +type LeftSidebarPersonalConfig = { + uid: string; + sections: LeftSidebarPersonalSectionConfig[]; +}; + +export const useLeftSidebarConfig = () => { + const [isInitialized, setIsInitialized] = useState(false); + const [settings, setSettings] = useState(null); + const [error, setError] = useState(null); + + const refreshSettings = useCallback(() => { + refreshConfigTree(); + const config = getFormattedConfigTree(); + setSettings(config); + return config; + }, []); + + useEffect(() => { + const initialize = async () => { + try { + await ensureLeftSidebarReady(); + const config = getFormattedConfigTree(); + setSettings(config); + setIsInitialized(true); + } catch (err) { + setError(err as Error); + const config = getFormattedConfigTree(); + setSettings(config); + } + }; + + void initialize(); + }, []); + + return { + settings, + setSettings, + refreshSettings, + isInitialized, + error, + }; +}; + +const LeftSidebarGlobalSectionsContent = ({ + globalSection, + refreshSettings, +}: { + globalSection: LeftSidebarGlobalSectionConfig; + refreshSettings: () => FormattedConfigTree; +}) => { + const parentUid = globalSection.uid; + + const [pages, setPages] = useState(globalSection.children); + const [newPageInputs, setNewPageInputs] = useState>( + {}, + ); + const [autocompleteKeys, setAutocompleteKeys] = useState< + Record + >({}); + + useEffect(() => { + setPages(globalSection.children); + }, [globalSection.children]); + + const refreshPages = useCallback(() => { + const newConfig = refreshSettings(); + setPages(newConfig.leftSidebar.global.children); + }, [refreshSettings]); + + const addPage = async (page: string) => { + if (!page || pages.some((p) => p.text === page)) { + return; + } + try { + await createBlock({ + parentUid: globalSection.childrenUid, + order: "last", + node: { text: page }, + }); + refreshPages(); + setNewPageInputs((prev) => ({ + ...prev, + [globalSection.childrenUid]: "", + })); + setAutocompleteKeys((prev) => ({ + ...prev, + [globalSection.childrenUid]: (prev[globalSection.childrenUid] || 0) + 1, + })); + } catch (error) { + console.error("Failed to add page:", error); + } + }; + + const removePage = useCallback( + async (page: RoamBasicNode) => { + try { + await deleteBlock(page.uid); + refreshPages(); + } catch (error) { + console.error("Failed to remove page:", error); + } + }, + [refreshPages], + ); + + const getPageInput = () => newPageInputs[globalSection.childrenUid] || ""; + const setPageInput = useCallback( + (value: string) => { + setTimeout(() => { + setNewPageInputs((prev) => ({ + ...prev, + [globalSection.childrenUid]: value, + })); + }, 0); + }, + [globalSection.childrenUid], + ); + const getAutocompleteKey = () => + autocompleteKeys[globalSection.childrenUid] || 0; + + return ( +
+ +
{ + if (e.key === "Enter" && getPageInput()) { + e.preventDefault(); + e.stopPropagation(); + addPage(getPageInput()); + } + }} + > + +
+
+ {pages.map((p) => ( +
+ {p.text} +
+ ))} +
+
+ ); +}; + +export const LeftSidebarGlobalSections = () => { + const { settings, refreshSettings, isInitialized, error } = + useLeftSidebarConfig(); + + if (error) { + console.error("Failed to initialize left sidebar structure:", error); + } + + if (!isInitialized || !settings) { + return
Loading settings...
; + } + + return ( + + ); +}; + +const LeftSidebarPersonalSectionsContent = ({ + personalSection, + refreshSettings, +}: { + personalSection: LeftSidebarPersonalConfig; + refreshSettings: () => FormattedConfigTree; +}) => { + const [sections, setSections] = useState( + personalSection.sections || [], + ); + const [newSectionInput, setNewSectionInput] = useState(""); + const [autocompleteKey, setAutocompleteKey] = useState(0); + const [sectionChildInputs, setSectionChildInputs] = useState< + Record + >({}); + const [childAutocompleteKeys, setChildAutocompleteKeys] = useState< + Record + >({}); + const [settingsDialogSectionUid, setSettingsDialogSectionUid] = useState< + string | null + >(null); + const [expandedChildLists, setExpandedChildLists] = useState>( + new Set(), + ); + + useEffect(() => { + setSections(personalSection.sections || []); + }, [personalSection.sections]); + + const refreshSections = useCallback(() => { + const newConfig = refreshSettings(); + setSections(newConfig.leftSidebar.personal.sections || []); + }, [refreshSettings]); + + const addSection = async (sectionName: string) => { + if (!sectionName || sections.some((s) => s.text === sectionName)) { + return; + } + + try { + await createBlock({ + parentUid: personalSection.uid, + order: "last", + node: { text: sectionName }, + }); + refreshSections(); + setNewSectionInput(""); + setAutocompleteKey((prev) => prev + 1); + } catch (error) { + console.error("Failed to add section:", error); + } + }; + + const removeSection = useCallback( + async (section: LeftSidebarPersonalSectionConfig) => { + try { + await deleteBlock(section.uid); + refreshSections(); + } catch (error) { + console.error("Failed to remove section:", error); + } + }, + [refreshSections], + ); + + const toggleChildrenList = (sectionUid: string) => { + setExpandedChildLists((prev) => { + const next = new Set(prev); + if (next.has(sectionUid)) next.delete(sectionUid); + else next.add(sectionUid); + return next; + }); + }; + + const convertToComplexSection = async ( + section: LeftSidebarPersonalSectionConfig, + ) => { + try { + await createBlock({ + parentUid: section.uid, + order: 0, + node: { + text: "Settings", + children: [ + { text: "Collapsable?", children: [{ text: "true" }] }, + { text: "Open?", children: [{ text: "true" }] }, + { text: "Truncate-result?", children: [{ text: "75" }] }, + ], + }, + }); + + await createBlock({ + parentUid: section.uid, + order: 1, + node: { text: "Children" }, + }); + + refreshSections(); + setSettingsDialogSectionUid(section.uid); + } catch (error) { + console.error("Failed to convert to complex section:", error); + } + }; + + const addChildToSection = async (childrenUid: string, childName: string) => { + if (!childName) return; + + try { + await createBlock({ + parentUid: childrenUid, + order: "last", + node: { text: childName }, + }); + refreshSections(); + setSectionChildInputs((prev) => ({ ...prev, [childrenUid]: "" })); + setChildAutocompleteKeys((prev) => ({ + ...prev, + [childrenUid]: (prev[childrenUid] || 0) + 1, + })); + } catch (error) { + console.error("Failed to add child:", error); + } + }; + + const removeChild = async (child: RoamBasicNode) => { + try { + await deleteBlock(child.uid); + refreshSections(); + } catch (error) { + console.error("Failed to remove child:", error); + } + }; + + const renderSectionSettings = (section: LeftSidebarPersonalSectionConfig) => { + if (section.isSimple || !section.settings) return null; + + return ( +
+ + + + +
+ ); + }; + + const renderSectionChildren = (section: LeftSidebarPersonalSectionConfig) => { + if (section.isSimple || !section.children) return null; + + const inputKey = section.childrenUid!; + const childInput = sectionChildInputs[inputKey] || ""; + const setChildInput = (value: string) => { + setTimeout(() => { + setSectionChildInputs((prev) => ({ ...prev, [inputKey]: value })); + }, 0); + }; + + return ( +
+
+ Children Pages +
+
{ + if (e.key === "Enter" && childInput) { + e.preventDefault(); + e.stopPropagation(); + addChildToSection(section.childrenUid!, childInput); + } + }} + > + +
+
+ {(section.children || []).map((child) => ( +
+ {child.text} +
+ ))} +
+
+ ); + }; + + const activeDialogSection = + sections.find((s) => s.uid === settingsDialogSectionUid) || null; + const ref = extractRef(activeDialogSection?.text); + const blockText = getTextByBlockUid(ref); + const alias = activeDialogSection?.settings?.alias?.value; + + return ( +
+
+
+ Add pages or create custom sections with settings and children +
+
{ + if (e.key === "Enter" && newSectionInput) { + e.preventDefault(); + e.stopPropagation(); + addSection(newSectionInput); + } + }} + > + +
+
+ +
+ {sections.map((section) => ( + + ))} +
+ + {activeDialogSection && ( + setSettingsDialogSectionUid(null)} + title={`Edit "${alias || blockText || activeDialogSection.text}"`} + > +
+ {renderSectionSettings(activeDialogSection)} + {renderSectionChildren(activeDialogSection)} +
+
+ )} +
+ ); +}; + +export const LeftSidebarPersonalSections = () => { + const { settings, refreshSettings, isInitialized, error } = + useLeftSidebarConfig(); + + if (!isInitialized || !settings) { + return
Loading settings...
; + } + + if (error) { + console.error("Failed to initialize left sidebar structure:", error); + } + + return ( + + ); +}; diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index 058c5f103..7cffa9f43 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -25,6 +25,10 @@ import sendErrorEmail from "~/utils/sendErrorEmail"; import HomePersonalSettings from "./HomePersonalSettings"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { FeedbackWidget } from "~/components/BirdEatsBugs"; +import { + LeftSidebarGlobalSections, + LeftSidebarPersonalSections, +} from "./LeftSidebar"; import { getVersionWithDate } from "~/utils/getVersion"; type SectionHeaderProps = { @@ -161,6 +165,20 @@ export const SettingsDialog = ({ className="overflow-y-auto" panel={} /> + + Left Sidebar + } + /> + } + /> Grammar { @@ -47,6 +52,7 @@ export const getFormattedConfigTree = (): FormattedConfigTree => { tree: configTreeRef.tree, text: "Canvas Page Format", }), + leftSidebar: getLeftSidebarSettings(configTreeRef.tree), }; }; export default configTreeRef; diff --git a/apps/roam/src/utils/ensureLeftSidebarStructure.ts b/apps/roam/src/utils/ensureLeftSidebarStructure.ts new file mode 100644 index 000000000..fe72b4965 --- /dev/null +++ b/apps/roam/src/utils/ensureLeftSidebarStructure.ts @@ -0,0 +1,119 @@ +import createBlock from "roamjs-components/writes/createBlock"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; +import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; +import { getLeftSidebarPersonalSectionConfig } from "./getLeftSidebarSettings"; +import { getLeftSidebarGlobalSectionConfig } from "./getLeftSidebarSettings"; +import { LeftSidebarConfig } from "./getLeftSidebarSettings"; +import { RoamBasicNode } from "roamjs-components/types"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; +import { getSubTree } from "roamjs-components/util"; +import refreshConfigTree from "~/utils/refreshConfigTree"; + +export const ensureLeftSidebarStructure = async (): Promise => { + const configPageUid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); + const configTree = getBasicTreeByParentUid(configPageUid); + const userName = getCurrentUserDisplayName(); + + let sidebarNode = configTree.find((n) => n.text === "Left Sidebar"); + + if (!sidebarNode) { + await createBlock({ + parentUid: configPageUid, + node: { + text: "Left Sidebar", + children: [ + { + text: "Global Section", + children: [ + { text: "Open", children: [{ text: "false" }] }, + { text: "Children" }, + ], + }, + { + text: userName + "/Personal Section", + }, + ], + }, + }); + } else { + const hasGlobalSection = sidebarNode.children?.some( + (n) => n.text === "Global Section", + ); + const hasPersonalSection = sidebarNode.children?.some( + (n) => n.text === userName + "/Personal Section", + ); + + if (!hasGlobalSection) { + await createBlock({ + parentUid: sidebarNode.uid, + order: 0, + node: { + text: "Global Section", + children: [ + { text: "Open", children: [{ text: "false" }] }, + { text: "Children" }, + ], + }, + }); + } + + if (!hasPersonalSection) { + await createBlock({ + parentUid: sidebarNode.uid, + node: { text: userName + "/Personal Section" }, + }); + } + } +}; + +let ensureLeftSidebarReadyPromise: Promise | null = null; + +export const ensureLeftSidebarReady = (): Promise => { + if (!ensureLeftSidebarReadyPromise) { + ensureLeftSidebarReadyPromise = (async () => { + await ensureLeftSidebarStructure(); + refreshConfigTree(); + })().catch((e) => { + ensureLeftSidebarReadyPromise = null; + throw e; + }); + } + return ensureLeftSidebarReadyPromise; +}; + +export const getLeftSidebarSettingsWithDefaults = ( + globalTree: RoamBasicNode[], +): LeftSidebarConfig => { + const leftSidebarNode = globalTree.find( + (node) => node.text === "Left Sidebar", + ); + const leftSidebarChildren = leftSidebarNode?.children || []; + const userName = getCurrentUserDisplayName(); + + const personalLeftSidebarNode = getSubTree({ + tree: leftSidebarChildren, + key: userName + "/Personal Section", + }); + + const global = getLeftSidebarGlobalSectionConfig(leftSidebarChildren) || { + uid: "", + open: { uid: "", value: false }, + children: [], + childrenUid: "", + }; + + const personal = getLeftSidebarPersonalSectionConfig( + personalLeftSidebarNode, + ) || { + uid: "", + text: "", + isSimple: true, + sections: [], + }; + + return { + global, + personal, + }; +}; diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts new file mode 100644 index 000000000..414369086 --- /dev/null +++ b/apps/roam/src/utils/getLeftSidebarSettings.ts @@ -0,0 +1,172 @@ +import { RoamBasicNode } from "roamjs-components/types"; +import { + BooleanSetting, + getUidAndBooleanSetting, + getUidAndStringSetting, + IntSetting, + getUidAndIntSetting, + StringSetting, +} from "./getExportSettings"; +import { getSubTree } from "roamjs-components/util"; +import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName"; + +export type LeftSidebarPersonalSectionSettings = { + uid: string; + truncateResult: IntSetting; + collapsable: BooleanSetting; + open: BooleanSetting; + alias: StringSetting | null; +}; + +export type LeftSidebarPersonalSectionConfig = { + uid: string; + text: string; + isSimple: boolean; + settings?: LeftSidebarPersonalSectionSettings; + children?: RoamBasicNode[]; + childrenUid?: string; +}; + +export type LeftSidebarGlobalSectionConfig = { + uid: string; + open: BooleanSetting; + children: RoamBasicNode[]; + childrenUid: string; +}; + +export type LeftSidebarConfig = { + global: LeftSidebarGlobalSectionConfig; + personal: { + uid: string; + sections: LeftSidebarPersonalSectionConfig[]; + }; +}; + +export const getLeftSidebarSettings = ( + globalTree: RoamBasicNode[], +): LeftSidebarConfig => { + const leftSidebarNode = globalTree.find( + (node) => node.text === "Left Sidebar", + ); + const leftSidebarChildren = leftSidebarNode?.children || []; + const userName = getCurrentUserDisplayName(); + + const personalLeftSidebarNode = getSubTree({ + tree: leftSidebarChildren, + key: userName + "/Personal Section", + }); + + const global = getLeftSidebarGlobalSectionConfig(leftSidebarChildren); + const personal = getLeftSidebarPersonalSectionConfig(personalLeftSidebarNode); + + return { + global, + personal, + }; +}; + +export const getLeftSidebarGlobalSectionConfig = ( + leftSidebarChildren: RoamBasicNode[], +): LeftSidebarGlobalSectionConfig => { + const globalSectionNode = leftSidebarChildren.find( + (node) => node.text === "Global Section", + ); + const globalChildren = globalSectionNode?.children || []; + + const getBoolean = (text: string) => + getUidAndBooleanSetting({ tree: globalChildren, text }); + + const childrenNode = globalChildren.find((node) => node.text === "Children"); + + return { + uid: globalSectionNode?.uid || "", + open: getBoolean("Open"), + children: childrenNode?.children || [], + childrenUid: childrenNode?.uid || "", + }; +}; + +export const getLeftSidebarPersonalSectionConfig = ( + personalContainerNode: RoamBasicNode, +): { uid: string; sections: LeftSidebarPersonalSectionConfig[] } => { + const sections = (personalContainerNode?.children || []).map( + (sectionNode): LeftSidebarPersonalSectionConfig => { + const hasSettings = sectionNode.children?.some( + (child) => child.text === "Settings", + ); + const childrenNode = sectionNode.children?.find( + (child) => child.text === "Children", + ); + + const isSimple = !hasSettings && !childrenNode; + + if (isSimple) { + return { + uid: sectionNode.uid, + text: sectionNode.text, + isSimple: true, + }; + } else { + return { + uid: sectionNode.uid, + text: sectionNode.text, + isSimple: false, + settings: getPersonalSectionSettings(sectionNode), + children: childrenNode?.children || [], + childrenUid: childrenNode?.uid || "", + }; + } + }, + ); + + return { + uid: personalContainerNode?.uid || "", + sections, + }; +}; + +export const getPersonalSectionSettings = ( + personalSectionNode: RoamBasicNode, +): LeftSidebarPersonalSectionSettings => { + const settingsNode = personalSectionNode.children?.find( + (node) => node.text === "Settings", + ); + const settingsTree = settingsNode?.children || []; + + const getInt = (text: string) => + getUidAndIntSetting({ tree: settingsTree, text }); + const getBoolean = (text: string) => + getUidAndBooleanSetting({ tree: settingsTree, text }); + const getString = (text: string) => + getUidAndStringSetting({ tree: settingsTree, text }); + + const truncateResultSetting = getInt("Truncate-result?"); + if (!settingsNode?.uid || !truncateResultSetting.uid) { + truncateResultSetting.value = 75; + } + + const collapsableSetting = getBoolean("Collapsable?"); + if (!settingsNode?.uid || !collapsableSetting.uid) { + collapsableSetting.value = false; + } + + const openSetting = getBoolean("Open?"); + if (!settingsNode?.uid || !openSetting.uid) { + openSetting.value = false; + } + + const aliasString = getString("Alias"); + const alias = + aliasString.value === "Alias" || + (aliasString.value === "" && aliasString.uid) + ? null + : aliasString; + + return { + uid: settingsNode?.uid || "", + truncateResult: truncateResultSetting, + collapsable: collapsableSetting, + open: openSetting, + alias, + }; +}; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index bce287b0a..69b08fdcc 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -44,6 +44,7 @@ import { findBlockElementFromSelection, } from "~/utils/renderTextSelectionPopup"; import { renderNodeTagPopupButton } from "./renderNodeTagPopup"; +import { mountLeftSidebarInto } from "~/components/LeftSidebarView"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -106,6 +107,32 @@ export const initObservers = async ({ }, }); + const attemptImmediateMount = () => { + try { + const candidate = document.querySelector( + ".roam-sidebar-container .starred-pages-wrapper", + ) as HTMLDivElement | null; + if (candidate) mountLeftSidebarInto(candidate); + } catch (e) { + console.error("[DG][LeftSidebar] attemptImmediateMount error", e); + } + }; + attemptImmediateMount(); + + const leftSidebarObserver = createHTMLObserver({ + tag: "DIV", + useBody: true, + className: "starred-pages-wrapper", + callback: (el) => { + try { + const container = el as HTMLDivElement; + mountLeftSidebarInto(container); + } catch (e) { + console.error("[DG][LeftSidebar] leftSidebarObserver error", e); + } + }, + }); + const pageActionListener = (( e: CustomEvent<{ action: string; @@ -316,6 +343,7 @@ export const initObservers = async ({ linkedReferencesObserver, graphOverviewExportObserver, nodeTagPopupButtonObserver, + leftSidebarObserver, ].filter((o): o is MutationObserver => !!o), listeners: { pageActionListener,