diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx new file mode 100644 index 000000000..8e2628551 --- /dev/null +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -0,0 +1,438 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import ReactDOM from "react-dom"; +import { + Collapse, + Icon, + Popover, + Menu, + MenuItem, + MenuDivider, + Divider, + Position, + PopoverInteractionKind, + TabId, +} 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, + notify, + subscribe, +} 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"; +import refreshConfigTree from "~/utils/refreshConfigTree"; +import { Dispatch, SetStateAction } from "react"; +import { SettingsDialog } from "./settings/Settings"; +import { OnloadArgs } from "roamjs-components/types"; +import renderOverlay from "roamjs-components/util/renderOverlay"; + +const parseReference = (text: string) => { + const extracted = extractRef(text); + if (text.startsWith("((") && text.endsWith("))")) { + return { type: "block" as const, uid: extracted, display: text }; + } else { + return { type: "page" 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, sectionTitle: string) => { + e.preventDefault(); + e.stopPropagation(); + const target = parseReference(sectionTitle); + if (target.type === "block") { + if (e.shiftKey) { + await openBlockInSidebar(target.uid); + return; + } + await window.roamAlphaAPI.ui.mainWindow.openBlock({ + block: { uid: target.uid }, + }); + return; + } + + const uid = getPageUidByPageTitle(sectionTitle); + if (!uid) return; + if (e.shiftKey) { + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + // @ts-expect-error - todo test + // eslint-disable-next-line @typescript-eslint/naming-convention + window: { type: "outline", "block-uid": uid }, + }); + } else { + await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + } +}; + +const toggleFoldedState = ({ + isOpen, + setIsOpen, + folded, + parentUid, +}: { + isOpen: boolean; + setIsOpen: Dispatch>; + folded: { uid?: string; value: boolean }; + parentUid: string; +}) => { + if (isOpen) { + setIsOpen(false); + if (folded.uid) { + void deleteBlock(folded.uid); + folded.uid = undefined; + folded.value = false; + } + } else { + setIsOpen(true); + const newUid = window.roamAlphaAPI.util.generateUID(); + void createBlock({ + parentUid, + node: { text: "Folded", uid: newUid }, + }); + folded.uid = newUid; + folded.value = true; + } +}; + +const SectionChildren = ({ + childrenNodes, + truncateAt, +}: { + childrenNodes: { uid: string; text: string }[]; + truncateAt?: number; +}) => { + 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) => { + return void openTarget(e, child.text); + }; + return ( +
+
+ {label} +
+
+ ); + })} + + ); +}; + +const PersonalSectionItem = ({ + section, +}: { + section: LeftSidebarPersonalSectionConfig; +}) => { + const titleRef = parseReference(section.text); + const blockText = useMemo( + () => + titleRef.type === "block" ? getTextByBlockUid(titleRef.uid) : undefined, + [titleRef], + ); + const truncateAt = section.settings?.truncateResult.value; + const [isOpen, setIsOpen] = useState( + !!section.settings?.folded.value || false, + ); + const alias = section.settings?.alias?.value; + + const handleChevronClick = () => { + if (!section.settings) return; + + toggleFoldedState({ + isOpen, + setIsOpen, + folded: section.settings.folded, + parentUid: section.settings.uid || "", + }); + }; + + return ( + <> +
+
+
{ + if ((section.children?.length || 0) > 0) { + handleChevronClick(); + } else { + void openTarget(e, section.text); + } + }} + > + {(alias || blockText || titleRef.display).toUpperCase()} +
+ {(section.children?.length || 0) > 0 && ( + + + + )} +
+
+ + + + + ); +}; + +const PersonalSections = ({ + config, +}: { + config: LeftSidebarConfig["personal"]; +}) => { + const sections = config.sections || []; + if (!sections.length) return null; + return ( +
+ {sections.map((s) => ( + + ))} +
+ ); +}; + +const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { + const [isOpen, setIsOpen] = useState( + !!config.settings?.folded.value, + ); + if (!config.children?.length) return null; + const isCollapsable = config.settings?.collapsable.value; + + return ( + <> +
{ + if (!isCollapsable || !config.settings) return; + toggleFoldedState({ + isOpen, + setIsOpen, + folded: config.settings.folded, + parentUid: config.settings.uid, + }); + }} + > +
+ GLOBAL + {isCollapsable && ( + + + + )} +
+
+ {isCollapsable ? ( + + + + ) : ( + + )} + + ); +}; + +export const useConfig = () => { + const [config, setConfig] = useState( + () => getFormattedConfigTree().leftSidebar, + ); + useEffect(() => { + const handleUpdate = () => { + setConfig(getFormattedConfigTree().leftSidebar); + }; + const unsubscribe = subscribe(handleUpdate); + return () => { + unsubscribe(); + }; + }, []); + return config; +}; + +export const refreshAndNotify = () => { + refreshConfigTree(); + notify(); +}; + +const FavouritesPopover = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const menuTriggerRef = useRef(null); + + const handleGlobalPointerDownCapture = useCallback( + (e: Event) => { + if (!isMenuOpen) return; + const target = e.target as Node | null; + if (!target) return; + + if (menuTriggerRef.current && menuTriggerRef.current.contains(target)) { + return; + } + const popoverEl = document.querySelector(".dg-leftsidebar-popover"); + if (popoverEl && popoverEl.contains(target)) { + return; + } + + setIsMenuOpen(false); + }, + [isMenuOpen], + ); + + useEffect(() => { + if (!isMenuOpen) return; + console.log("handleGlobalPointerDownCapture"); + const opts = { capture: true } as AddEventListenerOptions; + window.addEventListener( + "mousedown", + handleGlobalPointerDownCapture as EventListener, + opts, + ); + window.addEventListener( + "pointerdown", + handleGlobalPointerDownCapture as EventListener, + opts, + ); + return () => { + window.removeEventListener( + "mousedown", + handleGlobalPointerDownCapture as EventListener, + opts, + ); + window.removeEventListener( + "pointerdown", + handleGlobalPointerDownCapture as EventListener, + opts, + ); + }; + }, [handleGlobalPointerDownCapture]); + + const renderSettingsDialog = (tabId: TabId) => { + renderOverlay({ + Overlay: SettingsDialog, + props: { + onloadArgs, + selectedTabId: tabId, + }, + }); + }; + + return ( + <> + +
+
+ + +
+ FAVOURITES +
+ setIsMenuOpen(next)} + onClose={() => setIsMenuOpen(false)} + popoverClassName="dg-leftsidebar-popover" + minimal + content={ + + + { + renderSettingsDialog("left-sidebar-global-settings"); + setIsMenuOpen(false); + }} + /> + { + renderSettingsDialog("left-sidebar-personal-settings"); + setIsMenuOpen(false); + }} + /> + + } + > + + + + +
+ + ); +}; + +const LeftSidebarView = ({ onloadArgs }: { onloadArgs: OnloadArgs }) => { + const config = useConfig(); + return ( + <> + + + + + ); +}; + +export const mountLeftSidebar = ( + wrapper: HTMLElement, + onloadArgs: OnloadArgs, +): void => { + if (!wrapper) return; + wrapper.innerHTML = ""; + + const id = "dg-left-sidebar-root"; + let root = wrapper.querySelector(`#${id}`) as HTMLDivElement; + if (!root) { + const existingStarred = wrapper.querySelector(".starred-pages"); + if (existingStarred) { + existingStarred.remove(); + } + root = document.createElement("div"); + root.id = id; + root.className = "starred-pages overflow-scroll"; + root.onmousedown = (e) => e.stopPropagation(); + wrapper.appendChild(root); + } else { + root.className = "starred-pages overflow-scroll"; + } + ReactDOM.render(, root); +}; + +export default LeftSidebarView; diff --git a/apps/roam/src/components/settings/GeneralSettings.tsx b/apps/roam/src/components/settings/GeneralSettings.tsx index 76221eac2..ac1ab5cae 100644 --- a/apps/roam/src/components/settings/GeneralSettings.tsx +++ b/apps/roam/src/components/settings/GeneralSettings.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from "react"; import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel"; +import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel"; import { getFormattedConfigTree } from "~/utils/discourseConfigRef"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { DEFAULT_CANVAS_PAGE_FORMAT } from "~/index"; @@ -29,6 +30,14 @@ const DiscourseGraphHome = () => { value={settings.canvasPageFormat.value} defaultValue={DEFAULT_CANVAS_PAGE_FORMAT} /> + ); }; diff --git a/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx new file mode 100644 index 000000000..cebb853ae --- /dev/null +++ b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx @@ -0,0 +1,326 @@ +import React, { useCallback, useEffect, useMemo, useState, memo } from "react"; +import { Button, Collapse } from "@blueprintjs/core"; +import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel"; +import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import getAllPageNames from "roamjs-components/queries/getAllPageNames"; +import createBlock from "roamjs-components/writes/createBlock"; +import deleteBlock from "roamjs-components/writes/deleteBlock"; +import type { RoamBasicNode } from "roamjs-components/types"; +import { getSubTree } from "roamjs-components/util"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import discourseConfigRef from "~/utils/discourseConfigRef"; +import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; +import { getLeftSidebarGlobalSectionConfig } from "~/utils/getLeftSidebarSettings"; +import { LeftSidebarGlobalSectionConfig } from "~/utils/getLeftSidebarSettings"; +import { render as renderToast } from "roamjs-components/components/Toast"; +import refreshConfigTree from "~/utils/refreshConfigTree"; +import { refreshAndNotify } from "~/components/LeftSidebarView"; + +const PageItem = memo( + ({ + page, + onRemove, + }: { + page: RoamBasicNode; + onRemove: (page: RoamBasicNode) => void; + }) => { + return ( +
+ {page.text} +
+ ); + }, +); + +PageItem.displayName = "PageItem"; + +const LeftSidebarGlobalSectionsContent = ({ + leftSidebar, +}: { + leftSidebar: RoamBasicNode; +}) => { + const [globalSection, setGlobalSection] = + useState(null); + const [pages, setPages] = useState([]); + const [childrenUid, setChildrenUid] = useState(null); + const [newPageInput, setNewPageInput] = useState(""); + const [autocompleteKey, setAutocompleteKey] = useState(0); + const [isInitializing, setIsInitializing] = useState(true); + const [isExpanded, setIsExpanded] = useState(true); + + const pageNames = useMemo(() => getAllPageNames(), []); + + useEffect(() => { + const initialize = async () => { + setIsInitializing(true); + const globalSectionText = "Global-Section"; + const config = getLeftSidebarGlobalSectionConfig(leftSidebar.children); + + const existingGlobalSection = leftSidebar.children.find( + (n) => n.text === globalSectionText, + ); + + if (!existingGlobalSection) { + try { + const globalSectionUid = await createBlock({ + parentUid: leftSidebar.uid, + order: 0, + node: { text: globalSectionText }, + }); + const settingsUid = await createBlock({ + parentUid: globalSectionUid, + order: 0, + node: { text: "Settings" }, + }); + const childrenUid = await createBlock({ + parentUid: globalSectionUid, + order: 0, + node: { text: "Children" }, + }); + setChildrenUid(childrenUid || null); + setPages([]); + setGlobalSection({ + uid: globalSectionUid, + settings: { + uid: settingsUid, + collapsable: { uid: undefined, value: false }, + folded: { uid: undefined, value: false }, + }, + childrenUid, + children: [], + }); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to create global section", + intent: "danger", + id: "create-global-section-error", + }); + } + } else { + setChildrenUid(config.childrenUid || null); + setPages(config.children || []); + setGlobalSection(config); + } + setIsInitializing(false); + }; + + void initialize(); + }, [leftSidebar]); + + const addPage = useCallback( + async (pageName: string) => { + if (!pageName || !childrenUid) return; + + if (pages.some((p) => p.text === pageName)) { + console.warn(`Page "${pageName}" already exists in global section`); + return; + } + + try { + const newPageUid = await createBlock({ + parentUid: childrenUid, + order: "last", + node: { text: pageName }, + }); + + const newPage: RoamBasicNode = { + text: pageName, + uid: newPageUid, + children: [], + }; + + setPages((prev) => [...prev, newPage]); + setNewPageInput(""); + setAutocompleteKey((prev) => prev + 1); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to add page", + intent: "danger", + id: "add-page-error", + }); + } + }, + [childrenUid, pages], + ); + + const removePage = useCallback(async (page: RoamBasicNode) => { + try { + await deleteBlock(page.uid); + setPages((prev) => prev.filter((p) => p.uid !== page.uid)); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to remove page", + intent: "danger", + id: "remove-page-error", + }); + } + }, []); + + const handlePageInputChange = useCallback((value: string) => { + setNewPageInput(value); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && newPageInput) { + e.preventDefault(); + e.stopPropagation(); + void addPage(newPageInput); + } + }, + [newPageInput, addPage], + ); + + const toggleChildren = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const isAddButtonDisabled = useMemo( + () => !newPageInput || pages.some((p) => p.text === newPageInput), + [newPageInput, pages], + ); + + if (isInitializing || !globalSection) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+
+ + +
+ +
+
+
+
+ + {pages.length} {pages.length === 1 ? "page" : "pages"} + +
+ + +
+
+ Add pages that will appear for all users +
+
+ +
+ {pages.length > 0 ? ( +
+ {pages.map((page) => ( + void removePage(page)} + /> + ))} +
+ ) : ( +
+ No pages added yet +
+ )} +
+
+
+
+ ); +}; + +export const LeftSidebarGlobalSections = () => { + const [leftSidebar, setLeftSidebar] = useState(null); + + useEffect(() => { + const loadData = () => { + refreshConfigTree(); + + const configPageUid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); + const updatedSettings = discourseConfigRef.tree; + const leftSidebarNode = getSubTree({ + tree: updatedSettings, + parentUid: configPageUid, + key: "Left Sidebar", + }); + + setTimeout(() => { + refreshAndNotify(); + }, 10); + setLeftSidebar(leftSidebarNode); + }; + + void loadData(); + }, []); + + if (!leftSidebar) { + return null; + } + + return ; +}; diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx new file mode 100644 index 000000000..e4806f1d7 --- /dev/null +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -0,0 +1,586 @@ +import discourseConfigRef from "~/utils/discourseConfigRef"; +import React, { useCallback, useEffect, useMemo, 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, Tooltip } 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 { + LeftSidebarPersonalSectionConfig, + getLeftSidebarPersonalSectionConfig, +} from "~/utils/getLeftSidebarSettings"; +import { extractRef, getSubTree } from "roamjs-components/util"; +import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; +import { render as renderToast } from "roamjs-components/components/Toast"; +import refreshConfigTree from "~/utils/refreshConfigTree"; +import { refreshAndNotify } from "~/components/LeftSidebarView"; +import { memo, Dispatch, SetStateAction } from "react"; + +const SectionItem = memo( + ({ + section, + setSettingsDialogSectionUid, + pageNames, + setSections, + }: { + section: LeftSidebarPersonalSectionConfig; + setSections: Dispatch>; + setSettingsDialogSectionUid: (uid: string | null) => void; + pageNames: string[]; + }) => { + const ref = extractRef(section.text); + const blockText = getTextByBlockUid(ref); + const originalName = blockText || section.text; + const alias = section.settings?.alias?.value; + const [childInput, setChildInput] = useState(""); + const [childInputKey, setChildInputKey] = useState(0); + + const [expandedChildLists, setExpandedChildLists] = useState>( + new Set(), + ); + const isExpanded = expandedChildLists.has(section.uid); + const toggleChildrenList = useCallback((sectionUid: string) => { + setExpandedChildLists((prev) => { + const next = new Set(prev); + if (next.has(sectionUid)) { + next.delete(sectionUid); + } else { + next.add(sectionUid); + } + return next; + }); + }, []); + + const convertToComplexSection = useCallback( + async (section: LeftSidebarPersonalSectionConfig) => { + try { + const settingsUid = await createBlock({ + parentUid: section.uid, + order: 0, + node: { text: "Settings" }, + }); + const foldedUid = await createBlock({ + parentUid: settingsUid, + order: 0, + node: { text: "Folded" }, + }); + const truncateSettingUid = await createBlock({ + parentUid: settingsUid, + order: 1, + node: { text: "Truncate-result?", children: [{ text: "75" }] }, + }); + const aliasUid = await createBlock({ + parentUid: settingsUid, + order: 2, + node: { text: "Alias" }, + }); + + const childrenUid = await createBlock({ + parentUid: section.uid, + order: 1, + node: { text: "Children" }, + }); + + setSections((prev) => + prev.map((s) => { + if (s.uid === section.uid) { + return { + ...s, + settings: { + uid: settingsUid, + folded: { uid: foldedUid, value: false }, + truncateResult: { uid: truncateSettingUid, value: 75 }, + alias: { uid: aliasUid, value: "" }, + }, + childrenUid, + children: [], + }; + } + return s; + }), + ); + + setExpandedChildLists((prev) => new Set([...prev, section.uid])); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to convert to complex section", + intent: "danger", + id: "convert-to-complex-section-error", + }); + } + }, + [setSections], + ); + + const removeSection = useCallback( + async (section: LeftSidebarPersonalSectionConfig) => { + try { + await deleteBlock(section.uid); + + setSections((prev) => prev.filter((s) => s.uid !== section.uid)); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to remove section", + intent: "danger", + id: "remove-section-error", + }); + } + }, + [setSections], + ); + + const addChildToSection = useCallback( + async ( + section: LeftSidebarPersonalSectionConfig, + childrenUid: string, + childName: string, + ) => { + if (!childName || !childrenUid) return; + + try { + const newChild = await createBlock({ + parentUid: childrenUid, + order: "last", + node: { text: childName }, + }); + + setSections((prev) => + prev.map((s) => { + if (s.uid === section.uid) { + return { + ...s, + children: [ + ...(s.children || []), + { + text: childName, + uid: newChild, + children: [], + }, + ], + }; + } + return s; + }), + ); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to add child", + intent: "danger", + id: "add-child-error", + }); + } + }, + [setSections], + ); + const removeChild = useCallback( + async ( + section: LeftSidebarPersonalSectionConfig, + child: RoamBasicNode, + ) => { + try { + await deleteBlock(child.uid); + + setSections((prev) => + prev.map((s) => { + if (s.uid === section.uid) { + return { + ...s, + children: s.children?.filter((c) => c.uid !== child.uid), + }; + } + return s; + }), + ); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to remove child", + intent: "danger", + id: "remove-child-error", + }); + } + }, + [setSections], + ); + + const handleAddChild = useCallback(async () => { + if (childInput && section.childrenUid) { + await addChildToSection(section, section.childrenUid, childInput); + setChildInput(""); + setChildInputKey((prev) => prev + 1); + refreshAndNotify(); + } + }, [childInput, section, addChildToSection]); + + const sectionWithoutSettingsAndChildren = + !section.settings && !section.children; + + return ( +
+
+ {!sectionWithoutSettingsAndChildren && ( +
+ + {!sectionWithoutSettingsAndChildren && ( + +
+
{ + if (e.key === "Enter" && childInput) { + e.preventDefault(); + e.stopPropagation(); + void handleAddChild(); + } + }} + > + +
+ + {(section.children || []).length > 0 && ( +
+ {(section.children || []).map((child) => ( +
+ {child.text} +
+ ))} +
+ )} + + {(!section.children || section.children.length === 0) && ( +
+ No children added yet +
+ )} +
+
+ )} +
+ ); + }, +); + +SectionItem.displayName = "SectionItem"; + +const LeftSidebarPersonalSectionsContent = ({ + leftSidebar, +}: { + leftSidebar: RoamBasicNode; +}) => { + const [sections, setSections] = useState( + [], + ); + const [personalSectionUid, setPersonalSectionUid] = useState( + null, + ); + const [newSectionInput, setNewSectionInput] = useState(""); + const [autocompleteKey, setAutocompleteKey] = useState(0); + const [settingsDialogSectionUid, setSettingsDialogSectionUid] = useState< + string | null + >(null); + + useEffect(() => { + const initialize = async () => { + const userUid = window.roamAlphaAPI.user.uid(); + const personalSectionText = userUid + "/Personal-Section"; + + const personalSection = leftSidebar.children.find( + (n) => n.text === personalSectionText, + ); + + if (!personalSection) { + const newSectionUid = await createBlock({ + parentUid: leftSidebar.uid, + order: 0, + node: { + text: personalSectionText, + }, + }); + setPersonalSectionUid(newSectionUid); + setSections([]); + } else { + setPersonalSectionUid(personalSection.uid); + const loadedSections = getLeftSidebarPersonalSectionConfig( + leftSidebar.children, + ).sections; + setSections(loadedSections); + } + }; + + void initialize(); + }, [leftSidebar]); + + const addSection = useCallback( + async (sectionName: string) => { + if (!sectionName || !personalSectionUid) return; + if (sections.some((s) => s.text === sectionName)) return; + + try { + const newBlock = await createBlock({ + parentUid: personalSectionUid, + order: "last", + node: { text: sectionName }, + }); + + setSections((prev) => [ + ...prev, + { + text: sectionName, + uid: newBlock, + settings: undefined, + children: undefined, + childrenUid: undefined, + } as LeftSidebarPersonalSectionConfig, + ]); + + setNewSectionInput(""); + setAutocompleteKey((prev) => prev + 1); + refreshAndNotify(); + } catch (error) { + renderToast({ + content: "Failed to add section", + intent: "danger", + id: "add-section-error", + }); + } + }, + [personalSectionUid, sections], + ); + + const handleNewSectionInputChange = useCallback((value: string) => { + setNewSectionInput(value); + }, []); + + const activeDialogSection = useMemo(() => { + return sections.find((s) => s.uid === settingsDialogSectionUid) || null; + }, [sections, settingsDialogSectionUid]); + + const pageNames = useMemo(() => getAllPageNames(), []); + + if (!personalSectionUid) { + return null; + } + + return ( +
+
+
+ Add pages or create custom sections with settings and children +
+
{ + if (e.key === "Enter" && newSectionInput) { + e.preventDefault(); + e.stopPropagation(); + void addSection(newSectionInput); + } + }} + > + +
+
+ +
+ {sections.map((section) => ( + + ))} +
+ + {activeDialogSection && activeDialogSection.settings && ( + setSettingsDialogSectionUid(null)} + title={`Settings for "${activeDialogSection.text}"`} + style={{ width: "500px" }} + > +
+
+ + + { + setSections((prev) => + prev.map((s) => { + if (s.uid === activeDialogSection.uid && s.settings) { + return { + ...s, + settings: { + ...s.settings, + alias: { ...s.settings.alias, value }, + }, + }; + } + return s; + }), + ); + }} + /> +
+
+
+ )} +
+ ); +}; + +export const LeftSidebarPersonalSections = () => { + const [leftSidebar, setLeftSidebar] = useState(null); + + useEffect(() => { + const loadData = () => { + refreshConfigTree(); + + const configPageUid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); + const updatedSettings = discourseConfigRef.tree; + const leftSidebarNode = getSubTree({ + tree: updatedSettings, + parentUid: configPageUid, + key: "Left Sidebar", + }); + + setTimeout(() => { + refreshAndNotify(); + }, 10); + setLeftSidebar(leftSidebarNode); + }; + + void loadData(); + }, []); + + if (!leftSidebar) { + return null; + } + + return ; +}; diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index b210f4eda..08c31aff5 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -27,6 +27,8 @@ import refreshConfigTree from "~/utils/refreshConfigTree"; import { FeedbackWidget } from "~/components/BirdEatsBugs"; import SuggestiveModeSettings from "./SuggestiveModeSettings"; import { getVersionWithDate } from "~/utils/getVersion"; +import { LeftSidebarPersonalSections } from "./LeftSidebarPersonalSettings"; +import { LeftSidebarGlobalSections } from "./LeftSidebarGlobalSettings"; type SectionHeaderProps = { children: React.ReactNode; @@ -62,24 +64,27 @@ export const SettingsDialog = ({ onloadArgs, isOpen, onClose, + selectedTabId, }: { onloadArgs: OnloadArgs; isOpen?: boolean; onClose?: () => void; + selectedTabId?: TabId; }) => { const extensionAPI = onloadArgs.extensionAPI; const settings = getFormattedConfigTree(); const nodes = getDiscourseNodes().filter(excludeDefaultNodes); - const [selectedTabId, setSelectedTabId] = useState( - "discourse-graph-home-personal", + const [activeTabId, setActiveTabId] = useState( + selectedTabId ?? "discourse-graph-home-personal", ); + useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { if (e.ctrlKey && e.shiftKey && e.key === "A") { e.stopPropagation(); e.preventDefault(); - setSelectedTabId("secret-admin-panel"); + setActiveTabId("secret-admin-panel"); } }; @@ -127,8 +132,8 @@ export const SettingsDialog = ({ `} setSelectedTabId(id)} - selectedTabId={selectedTabId} + onChange={(id) => setActiveTabId(id)} + selectedTabId={activeTabId} vertical={true} renderActiveTabPanelOnly={true} > @@ -147,6 +152,12 @@ export const SettingsDialog = ({ className="overflow-y-auto" panel={} /> + } + /> Global Settings @@ -162,6 +173,12 @@ export const SettingsDialog = ({ className="overflow-y-auto" panel={} /> + } + /> } diff --git a/apps/roam/src/styles/styles.css b/apps/roam/src/styles/styles.css index caa4b3100..309914fb7 100644 --- a/apps/roam/src/styles/styles.css +++ b/apps/roam/src/styles/styles.css @@ -135,3 +135,9 @@ width: 100px; justify-content: space-between; } + +.sidebar-title-button:hover, +.sidebar-title-button-add:hover { + background-color: #10161a; + color: #f5f8fa; +} diff --git a/apps/roam/src/utils/discourseConfigRef.ts b/apps/roam/src/utils/discourseConfigRef.ts index 5156e40f2..b152a3930 100644 --- a/apps/roam/src/utils/discourseConfigRef.ts +++ b/apps/roam/src/utils/discourseConfigRef.ts @@ -4,6 +4,8 @@ import { StringSetting, ExportConfigWithUids, getUidAndStringSetting, + getUidAndBooleanSetting, + BooleanSetting, } from "./getExportSettings"; import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; @@ -21,6 +23,17 @@ const configTreeRef: { nodes: { [uid: string]: { text: string; children: RoamBasicNode[] } }; } = { tree: [], nodes: {} }; +const listeners = new Set<() => void>(); + +export const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export const notify = () => { + listeners.forEach((listener) => listener()); +}; + type FormattedConfigTree = { settingsUid: string; grammarUid: string; @@ -31,6 +44,7 @@ type FormattedConfigTree = { canvasPageFormat: StringSetting; suggestiveMode: SuggestiveModeConfigWithUids; leftSidebar: LeftSidebarConfig; + leftSidebarEnabled: BooleanSetting; }; export const getFormattedConfigTree = (): FormattedConfigTree => { @@ -59,6 +73,10 @@ export const getFormattedConfigTree = (): FormattedConfigTree => { }), suggestiveMode: getSuggestiveModeConfigAndUids(configTreeRef.tree), leftSidebar: getLeftSidebarSettings(configTreeRef.tree), + leftSidebarEnabled: getUidAndBooleanSetting({ + tree: configTreeRef.tree, + text: "(BETA) Left Sidebar", + }), }; }; export default configTreeRef; diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts index 0a4edeeee..a0c9a8dda 100644 --- a/apps/roam/src/utils/getLeftSidebarSettings.ts +++ b/apps/roam/src/utils/getLeftSidebarSettings.ts @@ -19,7 +19,6 @@ type LeftSidebarPersonalSectionSettings = { export type LeftSidebarPersonalSectionConfig = { uid: string; text: string; - sectionWithoutSettingsAndChildren: boolean; settings?: LeftSidebarPersonalSectionSettings; children?: RoamBasicNode[]; childrenUid?: string; @@ -31,7 +30,7 @@ type LeftSidebarGlobalSectionSettings = { folded: BooleanSetting; }; -type LeftSidebarGlobalSectionConfig = { +export type LeftSidebarGlobalSectionConfig = { uid: string; settings?: LeftSidebarGlobalSectionSettings; children: RoamBasicNode[]; @@ -39,6 +38,7 @@ type LeftSidebarGlobalSectionConfig = { }; export type LeftSidebarConfig = { + uid: string; global: LeftSidebarGlobalSectionConfig; personal: { uid: string; @@ -65,7 +65,7 @@ const getGlobalSectionSettings = ( }; }; -const getLeftSidebarGlobalSectionConfig = ( +export const getLeftSidebarGlobalSectionConfig = ( leftSidebarChildren: RoamBasicNode[], ): LeftSidebarGlobalSectionConfig => { const globalSectionNode = getSubTree({ @@ -131,7 +131,7 @@ const getPersonalSectionSettings = ( }; }; -const getLeftSidebarPersonalSectionConfig = ( +export const getLeftSidebarPersonalSectionConfig = ( leftSidebarChildren: RoamBasicNode[], ): { uid: string; sections: LeftSidebarPersonalSectionConfig[] } => { const userUid = window.roamAlphaAPI.user.uid(); @@ -156,27 +156,15 @@ const getLeftSidebarPersonalSectionConfig = ( const childrenNode = sectionNode.children?.find( (child) => child.text === "Children", ); - - const sectionWithoutSettingsAndChildren = !settingsNode && !childrenNode; - - if (sectionWithoutSettingsAndChildren) { - return { - uid: sectionNode.uid, - text: sectionNode.text, - sectionWithoutSettingsAndChildren: true, - }; - } else { - return { - uid: sectionNode.uid, - text: sectionNode.text, - settings: settingsNode - ? getPersonalSectionSettings(settingsNode) - : undefined, - children: childrenNode?.children || [], - childrenUid: childrenNode?.uid || "", - sectionWithoutSettingsAndChildren: false, - }; - } + return { + uid: sectionNode.uid, + text: sectionNode.text, + settings: settingsNode + ? getPersonalSectionSettings(settingsNode) + : undefined, + children: childrenNode?.children || [], + childrenUid: childrenNode?.uid || "", + }; }, ); @@ -192,10 +180,12 @@ export const getLeftSidebarSettings = ( const leftSidebarNode = globalTree.find( (node) => node.text === "Left Sidebar", ); + const leftSidebarUid = leftSidebarNode?.uid || ""; const leftSidebarChildren = leftSidebarNode?.children || []; const global = getLeftSidebarGlobalSectionConfig(leftSidebarChildren); const personal = getLeftSidebarPersonalSectionConfig(leftSidebarChildren); return { + uid: leftSidebarUid, global, personal, }; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index 98ff16a9d..723c52310 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -46,6 +46,8 @@ import { import { renderNodeTagPopupButton } from "./renderNodeTagPopup"; import { formatHexColor } from "~/components/settings/DiscourseNodeCanvasSettings"; import { getSetting } from "./extensionSettings"; +import { mountLeftSidebar } from "~/components/LeftSidebarView"; +import { getUidAndBooleanSetting } from "./getExportSettings"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -190,6 +192,24 @@ export const initObservers = async ({ ) as IKeyCombo) || undefined; const personalTrigger = personalTriggerCombo?.key; const personalModifiers = getModifiersFromCombo(personalTriggerCombo); + + const leftSidebarObserver = createHTMLObserver({ + tag: "DIV", + useBody: true, + className: "starred-pages-wrapper", + callback: (el) => { + const isLeftSidebarEnabled = getUidAndBooleanSetting({ + tree: configTree, + text: "(BETA) Left Sidebar", + }).value; + const container = el as HTMLDivElement; + if (isLeftSidebarEnabled) { + container.style.padding = "0"; + mountLeftSidebar(container, onloadArgs); + } + }, + }); + const handleNodeMenuRender = (target: HTMLElement, evt: KeyboardEvent) => { if ( target.tagName === "TEXTAREA" && @@ -327,6 +347,7 @@ export const initObservers = async ({ linkedReferencesObserver, graphOverviewExportObserver, nodeTagPopupButtonObserver, + leftSidebarObserver, ].filter((o): o is MutationObserver => !!o), listeners: { pageActionListener,