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
162 changes: 141 additions & 21 deletions apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,99 @@ export const MAX_WIDTH = "400px";

const ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent(WHITE_LOGO_SVG)}`;

const ROAM_PAGE_DROP_MIME_TYPE = "application/x-roam-page";
const ROAM_BLOCK_DROP_MIME_TYPE = "application/x-roam-uid";
const TEMP_DRAG_ATTR = "data-roamjs-canvas-page-ref-draggable";
const PAGE_REF_REGEX = /^\[\[(.+?)\]\]$/;

const getClosestPageRef = (target: EventTarget | null): HTMLElement | null =>
target instanceof HTMLElement
? target.closest<HTMLElement>(".rm-page-ref")
: null;

const getPageTitleFromPageRef = (pageRef: HTMLElement): string | undefined => {
const pageTitle =
pageRef.getAttribute("data-tag") ||
pageRef.getAttribute("data-link-title") ||
pageRef
.closest<HTMLElement>("[data-link-title]")
?.getAttribute("data-link-title");

return pageTitle?.replace(/\\"/g, '"') || undefined;
};

let pageRefDragSourceSubscriptionCount = 0;
let cleanupRoamPageRefDragSources: (() => void) | undefined;

const createRoamPageRefDragSourceCleanup = (): (() => void) => {
let activePageRef: HTMLElement | null = null;

const clearActivePageRef = (): void => {
if (activePageRef?.hasAttribute(TEMP_DRAG_ATTR)) {
activePageRef.draggable = false;
activePageRef.removeAttribute(TEMP_DRAG_ATTR);
}
activePageRef = null;
};

const handlePointerDown = (e: MouseEvent | PointerEvent): void => {
if (e.defaultPrevented || e.button !== 0) return;
const pageRef = getClosestPageRef(e.target);
if (!pageRef || pageRef.draggable || !getPageTitleFromPageRef(pageRef)) {
return;
}

clearActivePageRef();
activePageRef = pageRef;
pageRef.draggable = true;
pageRef.setAttribute(TEMP_DRAG_ATTR, "true");
};

const handleDragStart = (e: DragEvent): void => {
const pageRef = getClosestPageRef(e.target);
const pageTitle = pageRef ? getPageTitleFromPageRef(pageRef) : undefined;
if (pageTitle) {
e.dataTransfer?.setData(ROAM_PAGE_DROP_MIME_TYPE, pageTitle);
}
};

document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("mousedown", handlePointerDown, true);
document.addEventListener("pointerup", clearActivePageRef, true);
document.addEventListener("mouseup", clearActivePageRef, true);
document.addEventListener("pointercancel", clearActivePageRef, true);
document.addEventListener("dragstart", handleDragStart, true);
document.addEventListener("dragend", clearActivePageRef, true);

return () => {
clearActivePageRef();
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("mousedown", handlePointerDown, true);
document.removeEventListener("pointerup", clearActivePageRef, true);
document.removeEventListener("mouseup", clearActivePageRef, true);
document.removeEventListener("pointercancel", clearActivePageRef, true);
document.removeEventListener("dragstart", handleDragStart, true);
document.removeEventListener("dragend", clearActivePageRef, true);
};
};

const enableRoamPageRefDragSources = (): (() => void) => {
pageRefDragSourceSubscriptionCount += 1;
cleanupRoamPageRefDragSources ||= createRoamPageRefDragSourceCleanup();

let subscribed = true;
return () => {
if (!subscribed) return;
subscribed = false;
pageRefDragSourceSubscriptionCount -= 1;

if (pageRefDragSourceSubscriptionCount === 0) {
cleanupRoamPageRefDragSources?.();
cleanupRoamPageRefDragSources = undefined;
}
};
};

/** Valid file size for asset props; undefined when unknown (e.g. Roam/file API not a real File) to avoid persisting null. */
const getValidFileSize = (file: { size?: number }): number | undefined =>
typeof file.size === "number" && Number.isFinite(file.size) && file.size > 0
Expand Down Expand Up @@ -639,17 +732,22 @@ const TldrawCanvasShared = ({
return getUids(blockInput as HTMLDivElement).blockUid;
};

// Handle Roam block drag and drop
// Handle Roam page reference and block drag sources
useEffect(() => {
const disablePageRefDragSources = enableRoamPageRefDragSources();
const handleDragStart = (e: DragEvent) => {
const target = e.target as HTMLElement;
const uid = getBlockUidFromBullet(target);
if (getClosestPageRef(target)) return;

if (uid) e.dataTransfer?.setData("application/x-roam-uid", uid);
const uid = getBlockUidFromBullet(target);
if (uid) e.dataTransfer?.setData(ROAM_BLOCK_DROP_MIME_TYPE, uid);
};

document.addEventListener("dragstart", handleDragStart);
return () => document.removeEventListener("dragstart", handleDragStart);
return () => {
disablePageRefDragSources();
document.removeEventListener("dragstart", handleDragStart);
};
}, []);

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
Expand All @@ -658,7 +756,23 @@ const TldrawCanvasShared = ({

const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const uid = e.dataTransfer.getData("application/x-roam-uid");

const pageTitle = e.dataTransfer.getData(ROAM_PAGE_DROP_MIME_TYPE);
if (pageTitle && appRef.current && extensionAPI) {
posthog.capture("Canvas: Roam Page Dropped");
const dropPoint = appRef.current.screenToPage({
x: e.clientX,
y: e.clientY,
});
void appRef.current.putExternalContent({
type: "text",
text: `[[${pageTitle}]]`,
point: dropPoint,
});
return;
}

const uid = e.dataTransfer.getData(ROAM_BLOCK_DROP_MIME_TYPE);

if (!uid || !appRef.current || !extensionAPI) return;
posthog.capture("Canvas: Roam Block Dropped");
Expand Down Expand Up @@ -1267,30 +1381,36 @@ const InsideEditorAndUiContext = ({
try {
const text = content.text ?? "";

// Check for page reference: [[pageName]]
const pageMatch = text.match(/^\[\[(.+?)\]\]$/);
if (pageMatch?.[1]) {
const pageName = pageMatch[1];
const pageUid = getPageUidByPageTitle(pageName);
if (!pageUid) return await callDefaultTextHandler(content);

const tryCreatePageNodeShape = async (
title: string,
): Promise<boolean> => {
const pageUid = getPageUidByPageTitle(title);
if (!pageUid) return false;
const nodeType = findDiscourseNode({
uid: pageUid,
title: pageName,
title,
nodes: allNodes,
});
if (!nodeType) return await callDefaultTextHandler(content);

if (!nodeType) return false;
await createDiscourseNodeShape({
uid: pageUid,
nodeText: pageName,
nodeText: title,
nodeType: nodeType.type,
content,
});
posthog.capture("Canvas: Node Added from External Content", {
source: "page-reference",
});
return;
return true;
};

// Check for page reference: [[pageName]]
const pageMatch = text.match(PAGE_REF_REGEX);
if (pageMatch?.[1]) {
if (await tryCreatePageNodeShape(pageMatch[1])) {
posthog.capture("Canvas: Node Added from Text Content", {
source: "page-reference",
});
return;
}
return await callDefaultTextHandler(content);
}

// Check for block reference: ((uid))
Expand All @@ -1309,7 +1429,7 @@ const InsideEditorAndUiContext = ({
nodeType: "blck-node",
content,
});
posthog.capture("Canvas: Node Added from External Content", {
posthog.capture("Canvas: Node Added from Text Content", {
source: "block-reference",
});
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions apps/roam/src/components/results-view/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const KanbanCard = (card: {
) : cardView.mode === "link" ? (
<div className="p-2">
<a
className={"rm-page-ref"}
data-link-title={getPageTitleByPageUid(displayUid) || ""}
href={getRoamUrl(displayUid)}
onClick={(e) => {
if (e.shiftKey) {
Expand Down