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
87 changes: 22 additions & 65 deletions apps/roam/src/components/ModifyNodeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ import {
getNewDiscourseNodeText,
getReferencedNodeInFormat,
} from "~/utils/formatUtils";
import createDiscourseNode from "~/utils/createDiscourseNode";
import createDiscourseNode, {
handleImageCreation,
} from "~/utils/createDiscourseNode";
import { OnloadArgs } from "roamjs-components/types";
import { render as renderToast } from "roamjs-components/components/Toast";
import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
import resolveQueryBuilderRef from "~/utils/resolveQueryBuilderRef";
import runQuery from "~/utils/runQuery";
import posthog from "posthog-js";

export type ModifyNodeDialogMode = "create" | "edit";
Expand All @@ -58,6 +58,7 @@ export type ModifyNodeDialogProps = {
text: string;
uid: string;
action: string;
nodeType?: string;
}) => Promise<void>;
onClose: () => void;
};
Expand Down Expand Up @@ -273,66 +274,6 @@ const ModifyNodeDialog = ({
onClose();
}, [onClose]);

const addImageToPage = useCallback(
async ({
pageUid,
imageUrl,
configPageUid,
extensionAPI,
}: {
pageUid: string;
imageUrl: string;
configPageUid: string;
extensionAPI?: OnloadArgs["extensionAPI"];
}) => {
const discourseNodes = getDiscourseNodes();
const canvasSettings = Object.fromEntries(
discourseNodes.map((n) => [n.type, { ...n.canvasSettings }]),
);
const {
"query-builder-alias": qbAlias = "",
"key-image": isKeyImage = "",
"key-image-option": keyImageOption = "",
} = canvasSettings[configPageUid] || {};

const createOrUpdateImageBlock = async (imagePlaceholderUid?: string) => {
const imageMarkdown = `![](${imageUrl})`;
if (imagePlaceholderUid) {
await updateBlock({
uid: imagePlaceholderUid,
text: imageMarkdown,
});
} else {
await createBlock({
node: { text: imageMarkdown },
order: 0,
parentUid: pageUid,
});
}
};

if (!isKeyImage || !extensionAPI) {
await createOrUpdateImageBlock();
return;
}

if (keyImageOption === "query-builder") {
const parentUid = resolveQueryBuilderRef({ queryRef: qbAlias });
const results = await runQuery({
extensionAPI,
parentUid,
// due to query format
inputs: { NODETEXT: content.text, NODEUID: pageUid },
});
const imagePlaceholderUid = results.allProcessedResults[0]?.uid;
await createOrUpdateImageBlock(imagePlaceholderUid);
} else {
await createOrUpdateImageBlock();
}
},
[content.text],
);

const onSubmit = async () => {
if (!content.text.trim()) return;
if (!selectedNodeType && !isContentLocked) {
Expand All @@ -358,11 +299,13 @@ const ModifyNodeDialog = ({
if (imageUrl) {
const pageUid = content.uid || getPageUidByPageTitle(content.text);
if (pageUid) {
await addImageToPage({
await handleImageCreation({
pageUid,
imageUrl,
discourseNodes: getDiscourseNodes(),
configPageUid: selectedNodeType?.type || "",
imageUrl,
extensionAPI,
text: content.text,
});
}
}
Expand All @@ -371,6 +314,7 @@ const ModifyNodeDialog = ({
text: content.text,
uid: content.uid,
action: "create",
nodeType: selectedNodeType?.type,
});

onClose();
Expand Down Expand Up @@ -480,6 +424,7 @@ const ModifyNodeDialog = ({
text: formattedTitle,
uid: newPageUid,
action: "create",
nodeType: selectedNodeType?.type,
});
} else {
// Edit mode: update the existing block
Expand Down Expand Up @@ -515,6 +460,7 @@ const ModifyNodeDialog = ({
text: updatedContent,
uid: sourceBlockUid || content.uid,
action: "edit",
nodeType: selectedNodeType?.type,
});
}
onClose();
Expand Down Expand Up @@ -592,6 +538,17 @@ const ModifyNodeDialog = ({
</Label>
</div>

{imageUrl && (
<Label className="w-full">
Image
<img
src={imageUrl}
alt=""
className="mt-1 block max-h-40 max-w-full rounded"
/>
</Label>
)}

{/* Referenced Node Input */}
{referencedNode && !isContentLocked && mode === "create" && (
<Label className="w-full">
Expand Down
4 changes: 2 additions & 2 deletions apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
} from "tldraw";
import "tldraw/tldraw.css";
import tldrawStyles from "./tldrawStyles";
import { DragHandleOverlay } from "./overlays/DragHandleOverlay";
import { CanvasOverlays } from "./overlays/CanvasOverlays";
import { isDiscourseNodeShape } from "./canvasUtils";
import getDiscourseNodes, { DiscourseNode } from "~/utils/getDiscourseNodes";
import getDiscourseRelations, {
Expand Down Expand Up @@ -995,7 +995,7 @@ const TldrawCanvasShared = ({
const editorComponents: TLEditorComponents = {
...defaultEditorComponents,
OnTheCanvas: ToastListener,
InFrontOfTheCanvas: DragHandleOverlay,
InFrontOfTheCanvas: CanvasOverlays,
};
const customUiComponents: TLUiComponents = createUiComponents({
allNodes,
Expand Down
66 changes: 66 additions & 0 deletions apps/roam/src/components/canvas/convertShapeToDiscourseNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createShapeId, Editor, TLImageShape, TLShape } from "tldraw";
import type { OnloadArgs } from "roamjs-components/types";
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
import { DISCOURSE_NODE_SHAPE_TYPE } from "./DiscourseNodeUtil";

export const uploadImageShapeToRoam = async ({
editor,
shape,
}: {
editor: Editor;
shape: TLImageShape;
}): Promise<string | undefined> => {
const { assetId } = shape.props;
if (!assetId) return undefined;
const asset = editor.getAsset(assetId);
if (!asset || !asset.props.src) return undefined;
// Dropped and pasted files are already uploaded to Roam by the canvas handlers
if (asset.props.src.startsWith("https:")) return asset.props.src;
const file = await fetch(asset.props.src)
.then((r) => r.arrayBuffer())
.then((buf) => new File([buf], shape.id));
return window.roamAlphaAPI.util.uploadFile({ file });
};

export const replaceShapeWithDiscourseNode = async ({
editor,
extensionAPI,
shape,
nodeType,
text,
uid,
}: {
editor: Editor;
extensionAPI: OnloadArgs["extensionAPI"];
shape: TLShape;
nodeType: string;
text: string;
uid: string;
}): Promise<void> => {
const { x, y } = shape;
editor.deleteShapes([shape.id]);
const { h, w, imageUrl } = await calcCanvasNodeSizeAndImg({
nodeText: text,
extensionAPI,
nodeType,
uid,
});
editor.createShapes([
{
type: DISCOURSE_NODE_SHAPE_TYPE,
id: createShapeId(),
props: {
uid,
title: text,
h,
w,
imageUrl,
fontFamily: "sans",
size: "s",
nodeTypeId: nodeType,
},
x,
y,
},
]);
};
10 changes: 10 additions & 0 deletions apps/roam/src/components/canvas/overlays/CanvasOverlays.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";
import { DragHandleOverlay } from "./DragHandleOverlay";
import { ImageConvertOverlay } from "./ImageConvertOverlay";

export const CanvasOverlays = (): JSX.Element => (
<>
<DragHandleOverlay />
<ImageConvertOverlay />
</>
);
121 changes: 121 additions & 0 deletions apps/roam/src/components/canvas/overlays/ImageConvertOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useCallback, useState } from "react";
import { Button, Icon } from "@blueprintjs/core";
import { TLImageShape, TLShape, useEditor, useValue } from "tldraw";
import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
import posthog from "posthog-js";
import { renderModifyNodeDialog } from "~/components/ModifyNodeDialog";
import { dispatchToastEvent } from "~/components/canvas/ToastListener";
import {
replaceShapeWithDiscourseNode,
uploadImageShapeToRoam,
} from "~/components/canvas/convertShapeToDiscourseNode";

const BUTTON_INSET = 8;

const isConvertibleImage = (shape?: TLShape | null): shape is TLImageShape =>
shape?.type === "image" && !shape.isLocked;

export const ImageConvertOverlay = (): JSX.Element | null => {
const editor = useEditor();
const extensionAPI = useExtensionAPI();
const [uploading, setUploading] = useState(false);

const imageShape = useValue<TLImageShape | null>(
"imageConvertTarget",
() => {
if (!editor.isIn("select.idle")) return null;
const hovered = editor.getHoveredShape();
if (isConvertibleImage(hovered)) return hovered;
const selected = editor.getOnlySelectedShape();
return isConvertibleImage(selected) ? selected : null;
},
[editor],
);

const buttonPosition = useValue<{ left: number; top: number } | null>(
"imageConvertButtonPosition",
() => {
if (!imageShape) return null;
const bounds = editor.getShapePageBounds(imageShape.id);
if (!bounds) return null;
const vp = editor.pageToViewport({ x: bounds.minX, y: bounds.minY });
return { left: vp.x + BUTTON_INSET, top: vp.y + BUTTON_INSET };
},
[editor, imageShape?.id],
);

const openConvertDialog = useCallback(async () => {
if (!imageShape || !extensionAPI || uploading) return;
posthog.capture("Canvas: Image Convert Button Clicked");
setUploading(true);
try {
const src = await uploadImageShapeToRoam({ editor, shape: imageShape });
if (!src) {
dispatchToastEvent({
id: "tldraw-image-convert-no-asset",
title: "Could not read this image",
severity: "warning",
});
return;
}
renderModifyNodeDialog({
mode: "create",
initialValue: { text: "", uid: "" },
extensionAPI,
imageUrl: src,
onSuccess: async ({ text, uid, nodeType }) => {
const shape = editor.getShape(imageShape.id);
if (!shape || !nodeType) {
dispatchToastEvent({
id: "tldraw-image-convert-not-replaced",
title: "Node created, but the image could not be replaced",
severity: "warning",
});
return;
}
await replaceShapeWithDiscourseNode({
editor,
extensionAPI,
shape,
nodeType,
text,
uid,
});
},
onClose: () => {},
});
} catch (error) {
dispatchToastEvent({
id: "tldraw-image-convert-failed",
title: `Could not upload this image: ${String(error)}`,
severity: "error",
});
} finally {
setUploading(false);
}
}, [editor, extensionAPI, imageShape, uploading]);

if (!buttonPosition) return null;

return (
<div style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
<Button
small
className="absolute z-20 rounded border border-gray-300 bg-white shadow"
style={{
left: `${buttonPosition.left}px`,
top: `${buttonPosition.top}px`,
pointerEvents: "all",
}}
icon={<Icon icon="new-object" />}
loading={uploading}
title="Convert to discourse node"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
void openConvertDialog();
}}
/>
</div>
);
};
Loading