Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions apps/roam/src/components/canvas/CustomStylePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import React, { useEffect, useState } from "react";
import {
DefaultStylePanel,
DefaultStylePanelContent,
TLUiStylePanelProps,
createShapeId,
useEditor,
useRelevantStyles,
useValue,
} from "tldraw";
import { Button, Tab, Tabs } from "@blueprintjs/core";
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
import getDiscourseContextResults from "~/utils/getDiscourseContextResults";
import type { DiscourseContextResults } from "~/components/DiscourseContext";
import findDiscourseNode from "~/utils/findDiscourseNode";
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
import { withAutoCanvasRelationsSuppressed } from "./autoCanvasRelationsSuppression";
import { isDiscourseNodeShape } from "./canvasUtils";
import {
DISCOURSE_NODE_SHAPE_TYPE,
DiscourseNodeShape,
DiscourseNodeUtil,
} from "./DiscourseNodeUtil";
import { dispatchToastEvent } from "./ToastListener";

const NEW_NODE_OFFSET_PX = 80;
const NEW_NODE_GAP_PX = 24;

const ContextTabContent = ({ shape }: { shape: DiscourseNodeShape }) => {
const editor = useEditor();
const extensionAPI = useExtensionAPI();
const [results, setResults] = useState<DiscourseContextResults | null>(null);
const [failed, setFailed] = useState(false);
const [pendingUids, setPendingUids] = useState<string[]>([]);
const uid = shape.props.uid;

useEffect(() => {
let cancelled = false;
setResults(null);
setFailed(false);
getDiscourseContextResults({ uid })
.then((r) => {
if (!cancelled) setResults(r);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [uid]);

const nodeShapesByUid = useValue(
"discourse-node-shapes-by-uid",
() =>
new Map(
editor
.getCurrentPageShapes()
.filter((s): s is DiscourseNodeShape =>
isDiscourseNodeShape(editor, s),
)
.map((s) => [s.props.uid, s]),
),
[editor],
);

const removeFromCanvas = (nodeShape: DiscourseNodeShape) => {
const util = editor.getShapeUtil(nodeShape);
if (util instanceof DiscourseNodeUtil) {
util.deleteRelationsInCanvas({ shape: nodeShape });
}
editor.deleteShapes([nodeShape.id]);
};

const addToCanvas = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
if (!extensionAPI) return;
const node = findDiscourseNode({ uid: relatedUid });
if (!node) {
dispatchToastEvent({
id: "dg-context-tab-missing-node",
title: "Could not find a discourse node for this result.",
severity: "error",
});
return;
}
const { w, h, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: text,
uid: relatedUid,
nodeType: node.type,
extensionAPI,
});
const x = shape.x + shape.props.w + NEW_NODE_OFFSET_PX;
const columnBottoms = editor
.getCurrentPageShapes()
.filter((s): s is DiscourseNodeShape => isDiscourseNodeShape(editor, s))
.filter((s) => s.x < x + w && s.x + s.props.w > x)
.map((s) => s.y + s.props.h);
const y = columnBottoms.length
? Math.max(...columnBottoms) + NEW_NODE_GAP_PX
: shape.y;
const id = createShapeId();
withAutoCanvasRelationsSuppressed(() =>
editor.createShapes([
{
id,
type: DISCOURSE_NODE_SHAPE_TYPE,
x,
y,
props: {
uid: relatedUid,
title: text,
w,
h,
...(imageUrl && { imageUrl }),
size: "s",
fontFamily: "sans",
nodeTypeId: node.type,
},
},
]),
);
const created = editor.getShape<DiscourseNodeShape>(id);
if (!created) return;
const util = editor.getShapeUtil(created);
if (util instanceof DiscourseNodeUtil) {
await util.createExistingRelations({ shape: created });
}
};

const toggleCanvasPresence = async ({
relatedUid,
text,
}: {
relatedUid: string;
text: string;
}) => {
setPendingUids((prev) => [...prev, relatedUid]);
try {
const existing = nodeShapesByUid.get(relatedUid);
if (existing) {
removeFromCanvas(existing);
} else {
await addToCanvas({ relatedUid, text });
}
} catch {
dispatchToastEvent({
id: "dg-context-tab-toggle-failed",
title: "Failed to update the canvas for this result.",
severity: "error",
});
} finally {
setPendingUids((prev) => prev.filter((u) => u !== relatedUid));
}
};

if (failed) {
return <div className="p-3 text-sm">Failed to load relations.</div>;
}
if (results === null) {
return <div className="p-3 text-sm">Loading relations...</div>;
}
if (results.length === 0) {
return <div className="p-3 text-sm">No relations found.</div>;
}

return (
<div className="max-h-96 overflow-y-auto p-3">
{results.map((relation) => (
<div key={relation.label} className="mb-3 last:mb-0">
<div className="mb-1 text-xs font-semibold text-gray-500">
{relation.label}
</div>
<ul className="m-0 list-none p-0">
{Object.entries(relation.results).map(([relatedUid, result]) => {
const text = result.text ?? relatedUid;
const onCanvas = nodeShapesByUid.has(relatedUid);
return (
<li
key={relatedUid}
className="flex items-center justify-between gap-2 py-1"
>
<span
className="min-w-0 flex-1 truncate text-sm"
title={text}
>
{text}
</span>
<Button
minimal
small
icon={onCanvas ? "minus" : "plus"}
title={onCanvas ? "Remove from canvas" : "Add to canvas"}
loading={pendingUids.includes(relatedUid)}
onClick={() =>
void toggleCanvasPresence({ relatedUid, text })
}
/>
</li>
);
})}
</ul>
</div>
))}
</div>
);
};

const NodeCardPanelContent = ({ shape }: { shape: DiscourseNodeShape }) => {
const styles = useRelevantStyles();
const [activeTab, setActiveTab] = useState<"context" | "styling">("context");
return (
<div className="dg-node-style-panel px-2 pt-1">
<Tabs
id="dg-node-card-tabs"
selectedTabId={activeTab}
onChange={(tabId) =>
setActiveTab(tabId === "styling" ? "styling" : "context")
}
renderActiveTabPanelOnly
>
<Tab
id="context"
title="Context"
panel={<ContextTabContent shape={shape} />}
/>
<Tab
id="styling"
title="Styling"
panel={<DefaultStylePanelContent styles={styles} />}
/>
</Tabs>
</div>
);
};

export const CustomStylePanel = (props: TLUiStylePanelProps) => {
const editor = useEditor();
const selectedNodeShape = useValue(
"selected-discourse-node-shape",
() => {
const selected = editor.getOnlySelectedShape();
return selected && isDiscourseNodeShape(editor, selected)
? selected
: null;
},
[editor],
);
if (!selectedNodeShape) return <DefaultStylePanel {...props} />;
return (
<DefaultStylePanel {...props}>
<NodeCardPanelContent
key={selectedNodeShape.id}
shape={selectedNodeShape}
/>
</DefaultStylePanel>
);
};
25 changes: 1 addition & 24 deletions apps/roam/src/components/canvas/DiscourseNodeUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
toDomPrecision,
TLAnyShapeUtilConstructor,
} from "tldraw";
import React, { useState, useEffect, useRef, useMemo } from "react";
import React, { useEffect, useRef, useMemo } from "react";
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
import isLiveBlock from "roamjs-components/queries/isLiveBlock";
import updateBlock from "roamjs-components/writes/updateBlock";
Expand All @@ -41,7 +41,6 @@ import { loadImage } from "~/utils/loadImage";
import { getRelationColor } from "./DiscourseRelationShape/DiscourseRelationUtil";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys";
import DiscourseContextOverlay from "~/components/DiscourseContextOverlay";
import { getDiscourseNodeColors } from "~/utils/getDiscourseNodeColors";
import { render as renderToast } from "roamjs-components/components/Toast";
import { RenderRoamBlockString } from "~/utils/roamReactComponents";
Expand Down Expand Up @@ -449,16 +448,9 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
const {
canvasSettings: { alias = "", "key-image": isKeyImage = "" } = {},
} = discourseContext.nodes[getDiscourseNodeTypeId({ shape })] || {};
// eslint-disable-next-line react-hooks/rules-of-hooks
const isOverlayEnabled = useMemo(
() => getPersonalSetting<boolean>([PERSONAL_KEYS.overlayInCanvas]),
[],
);

const isEditing = this.editor.getEditingShapeId() === shape.id;
// eslint-disable-next-line react-hooks/rules-of-hooks
const [overlayMounted, setOverlayMounted] = useState(false);
// eslint-disable-next-line react-hooks/rules-of-hooks

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overlayMounted + the onPointerEnter handler below existed solely to defer overlay mounting until hover; they go with the overlay.

const dialogRenderedRef = useRef(false);

// Detect discourse node tags in block text for blck-node shapes
Expand Down Expand Up @@ -613,7 +605,6 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
maxHeight: shape.props.h,
boxSizing: "border-box",
}}
onPointerEnter={() => setOverlayMounted(true)}
>
<div
className="relative flex h-full min-h-0 w-full min-w-0 flex-col"
Expand Down Expand Up @@ -750,20 +741,6 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
fontSize: FONT_SIZES[shape.props.size],
}}
>
{overlayMounted && isOverlayEnabled && (
<div
className="roamjs-discourse-context-overlay-container absolute right-1 top-1"
onPointerDown={(e) => e.stopPropagation()}
>
<DiscourseContextOverlay
uid={shape.props.uid}
id={`${shape.id}-overlay`}
opacity="50"
textColor={textColor}
iconColor={textColor}
/>
</div>
)}
{showEmbeddedRoamBlock ? (
<div className="w-full min-w-0">
<RenderRoamBlockString
Expand Down
6 changes: 6 additions & 0 deletions apps/roam/src/components/canvas/tldrawStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,10 @@ export default /* css */ `
background-color: var(--color-muted-2);
opacity: 1;
}

/* Widen the style panel when it shows the node card Context/Styling tabs */
.tlui-style-panel:has(.dg-node-style-panel) {
width: 280px;
max-width: 280px;
}
`;
2 changes: 2 additions & 0 deletions apps/roam/src/components/canvas/uiOverrides.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { createOrUpdateArrowBinding } from "./DiscourseRelationShape/helpers";
import DiscourseGraphPanel from "./DiscourseToolPanel";
import type { CanvasNodeShortcuts } from "~/components/settings/utils/zodSchema";
import { CustomDefaultToolbar } from "./CustomDefaultToolbar";
import { CustomStylePanel } from "./CustomStylePanel";
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
import { CanvasSyncMode } from "./canvasSyncMode";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
Expand Down Expand Up @@ -545,6 +546,7 @@ export const createUiComponents = ({
canvasSyncMode: CanvasSyncMode;
}): TLUiComponents => {
return {
StylePanel: CustomStylePanel,
Toolbar: (props) => {
const tools = useTools();
return (
Expand Down
13 changes: 0 additions & 13 deletions apps/roam/src/components/settings/HomePersonalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { NodeSearchMenuTriggerSetting } from "../DiscourseNodeSearchMenu";
import {
DISCOURSE_TOOL_SHORTCUT_KEY,
AUTO_CANVAS_RELATIONS_KEY,
DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY,
STREAMLINE_STYLING_KEY,
DISALLOW_DIAGNOSTICS,
USE_STORED_RELATIONS,
Expand Down Expand Up @@ -239,18 +238,6 @@ const HomePersonalSettings = ({
}}
/>

<PersonalFlagPanel
title="(BETA) Overlay in canvas"
description={withDocsLink(
"Whether or not to overlay discourse context information over canvas nodes.",
ROAM_DOCS.discourseContextOverlay,
)}
settingKeys={[PERSONAL_KEYS.overlayInCanvas]}
initialValue={personalSettings[PERSONAL_KEYS.overlayInCanvas]}
onChange={(checked) => {
void setSetting(DISCOURSE_CONTEXT_OVERLAY_IN_CANVAS_KEY, checked);
}}
/>
<PersonalFlagPanel
title="Streamline styling"
description="Apply streamlined styling to your personal graph for a cleaner appearance."
Expand Down
4 changes: 0 additions & 4 deletions apps/roam/src/components/settings/utils/accessors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,6 @@ const PERSONAL_SCHEMA_PATH_TO_LEGACY_KEY = new Map<string, string>([
[pathKey([PERSONAL_KEYS.disableSidebarOpen]), "disable-sidebar-open"],
[pathKey([PERSONAL_KEYS.hideFeedbackButton]), "hide-feedback-button"],
[pathKey([PERSONAL_KEYS.autoCanvasRelations]), "auto-canvas-relations"],
[

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy migration iterates Object.values(PERSONAL_KEYS), so with the key gone this map entry is never looked up — old graphs simply skip migrating the dead setting.

pathKey([PERSONAL_KEYS.overlayInCanvas]),
"discourse-context-overlay-in-canvas",
],
[pathKey([PERSONAL_KEYS.streamlineStyling]), "streamline-styling"],
[pathKey([PERSONAL_KEYS.disableProductDiagnostics]), "disallow-diagnostics"],
[pathKey([PERSONAL_KEYS.discourseToolShortcut]), "discourse-tool-shortcut"],
Expand Down
1 change: 0 additions & 1 deletion apps/roam/src/components/settings/utils/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const PERSONAL_KEYS = {
disableSidebarOpen: "Disable sidebar open",
hideFeedbackButton: "Hide feedback button",
autoCanvasRelations: "Auto canvas relations",
overlayInCanvas: "Overlay in canvas",
streamlineStyling: "Streamline styling",
disableProductDiagnostics: "Disable product diagnostics",
discourseToolShortcut: "Discourse tool shortcut",
Expand Down
Loading