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
284 changes: 262 additions & 22 deletions apps/roam/src/components/canvas/Tldraw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
TLAssetId,
getHashForString,
TLShapeId,
TLShape,
TLStore,
TLStoreWithStatus,
useToasts,
Expand Down Expand Up @@ -99,7 +98,7 @@ import { isPluginTimerReady, waitForPluginTimer } from "~/utils/pluginTimer";
import { HistoryEntry } from "@tldraw/store";
import { TLRecord } from "@tldraw/tlschema";
import { WHITE_LOGO_SVG } from "~/icons";
import { BLOCK_REF_REGEX } from "roamjs-components/dom";
import { BLOCK_REF_REGEX, createHTMLObserver } from "roamjs-components/dom";
import { defaultHandleExternalTextContent } from "./defaultHandleExternalTextContent";
import {
CanvasSyncMode,
Expand All @@ -118,6 +117,7 @@ import posthog from "posthog-js";
import { getPersonalSetting } from "~/components/settings/utils/accessors";
import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys";
import { json, normalizeProps } from "~/utils/getBlockProps";
import { onPageRefObserverChange } from "~/utils/pageRefObserverHandlers";

declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
Expand Down Expand Up @@ -174,6 +174,219 @@ 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 PAGE_REF_DRAG_HANDLE_ATTR = "data-roamjs-canvas-page-ref-drag-handle";
const PAGE_REF_REGEX = /^\[\[(.+?)\]\]$/;

type PageRefDragSource = {
element: HTMLElement;
title: string;
};

const getPageRefDragSource = (
target: EventTarget | null,
): PageRefDragSource | null => {
if (!(target instanceof HTMLElement)) return null;

const pageRef = target.closest<HTMLElement>(".rm-page-ref");
const pageRefContainer = target.closest<HTMLElement>("[data-link-title]");
const element = pageRef || pageRefContainer;
if (!element) return null;

const pageTitle =
pageRef?.getAttribute("data-tag") ||
pageRef?.getAttribute("data-link-title") ||
pageRef
?.closest<HTMLElement>("[data-link-title]")
?.getAttribute("data-link-title") ||
pageRefContainer?.getAttribute("data-link-title");

const title = pageTitle?.replace(/\\"/g, '"');
return title ? { element, title } : null;
};

const createPageRefDragHandle = (): HTMLElement => {
const dragHandle = document.createElement("span");
dragHandle.className =
"absolute -top-0.5 left-full cursor-grab select-none rounded-sm bg-white px-0.5 text-gray-500 active:cursor-grabbing";
dragHandle.draggable = true;
dragHandle.textContent = "⠿";
dragHandle.title = "Drag page to canvas";
dragHandle.setAttribute("aria-label", "Drag page to canvas");
dragHandle.setAttribute(PAGE_REF_DRAG_HANDLE_ATTR, "true");
return dragHandle;
};

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

const createRoamPageRefDragSourceCleanup = (): (() => void) => {
const dragHandle = createPageRefDragHandle();
const observedPageRefs = new WeakSet<HTMLElement>();
let activePageRef: HTMLElement | null = null;
let didPositionActivePageRef = false;
let isDragHandlePointerDown = false;
let isPageRefDragInProgress = false;

const clearActivePageRef = (): void => {
dragHandle.remove();
if (activePageRef && didPositionActivePageRef) {
activePageRef.classList.remove("relative");
}
didPositionActivePageRef = false;
activePageRef = null;
};

const renderPageRefDragHandle = (target: EventTarget | null): void => {
if (isDragHandlePointerDown || isPageRefDragInProgress) return;

const source = getPageRefDragSource(target);
if (!source) {
if (activePageRef) clearActivePageRef();
return;
}
if (source.element === activePageRef) return;

activePageRef = source.element;
// The handle is absolutely positioned so hovering never reflows text;
// the ref itself must be the positioned ancestor.
if (getComputedStyle(source.element).position === "static") {
source.element.classList.add("relative");
didPositionActivePageRef = true;
}
source.element.appendChild(dragHandle);
};

const handlePageRefPointerEnter = (e: PointerEvent): void => {
renderPageRefDragHandle(e.currentTarget);
};

const handlePageRefPointerLeave = (e: PointerEvent): void => {
if (e.currentTarget !== activePageRef) return;
renderPageRefDragHandle(e.relatedTarget);
};

const observePageRef = (pageRef: HTMLElement): void => {
if (observedPageRefs.has(pageRef)) return;

observedPageRefs.add(pageRef);
pageRef.addEventListener("pointerenter", handlePageRefPointerEnter);
pageRef.addEventListener("pointerleave", handlePageRefPointerLeave);
};

const handleDragHandlePointerDown = (e: MouseEvent): void => {
if (e.button !== 0) return;

isDragHandlePointerDown = true;
e.stopPropagation();
};

const handlePointerUp = (e: PointerEvent): void => {
if (!isDragHandlePointerDown) return;

isDragHandlePointerDown = false;
if (
activePageRef &&
(!(e.target instanceof Node) || !activePageRef.contains(e.target))
) {
clearActivePageRef();
}
};

const handlePointerCancel = (): void => {
if (!isDragHandlePointerDown) return;

isDragHandlePointerDown = false;
clearActivePageRef();
};

const handleDragStart = (e: DragEvent): void => {
const source = getPageRefDragSource(dragHandle);
if (!source || !e.dataTransfer) return;

isDragHandlePointerDown = false;
isPageRefDragInProgress = true;
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData(ROAM_PAGE_DROP_MIME_TYPE, source.title);
};

const handleClick = (e: MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
};

const handleDragEnd = (): void => {
if (!isPageRefDragInProgress) return;

isPageRefDragInProgress = false;
// Roam arms invisible .dnd-drop-area overlays over every block when it
// sees dragstart, and only a dragleave disarms them. A drop consumed by
// the canvas never delivers one, leaving armed overlays that swallow
// every later page-ref hover - so deliver the dragleave here.
document
.querySelectorAll<HTMLElement>(".dnd-drop-area")
.forEach((area) =>
area.dispatchEvent(new DragEvent("dragleave", { bubbles: true })),
);
clearActivePageRef();
};

onPageRefObserverChange(observePageRef)(true);
// Query link/alias views render page refs as anchors, which the shared
// SPAN-only page-ref observer never sees. Observed locally so anchors are
// not fed to its other handlers (overlays, previews).
const pageRefAnchorObserver = createHTMLObserver({
useBody: true,
tag: "A",
className: "rm-page-ref",
callback: observePageRef,
});
Comment on lines +339 to +344

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Attach drag listeners to existing page-link anchors

When a query link/alias view has already rendered before this canvas effect runs, its a.rm-page-ref elements are never passed to observePageRef: createHTMLObserver only covers subsequently added nodes, while the shared span path explicitly applies its handler to existing refs via onPageRefObserverChange. Consequently, opening or mounting a canvas alongside an already-rendered query leaves those page links without a drag handle until the query rerenders. Query the existing a.rm-page-ref elements here as well before relying on the observer.

Useful? React with 👍 / 👎.

dragHandle.addEventListener("pointerdown", handleDragHandlePointerDown);
dragHandle.addEventListener("mousedown", handleDragHandlePointerDown);
Comment thread
sid597 marked this conversation as resolved.
dragHandle.addEventListener("dragstart", handleDragStart);
dragHandle.addEventListener("click", handleClick);
dragHandle.addEventListener("dragend", handleDragEnd);
document.addEventListener("pointerup", handlePointerUp, true);
document.addEventListener("pointercancel", handlePointerCancel, true);

return () => {
onPageRefObserverChange(observePageRef)(false);
pageRefAnchorObserver.disconnect();
document
.querySelectorAll<HTMLElement>("span.rm-page-ref, a.rm-page-ref")
.forEach((pageRef) => {
pageRef.removeEventListener("pointerenter", handlePageRefPointerEnter);
pageRef.removeEventListener("pointerleave", handlePageRefPointerLeave);
});
clearActivePageRef();
dragHandle.removeEventListener("pointerdown", handleDragHandlePointerDown);
dragHandle.removeEventListener("mousedown", handleDragHandlePointerDown);
dragHandle.removeEventListener("dragstart", handleDragStart);
dragHandle.removeEventListener("click", handleClick);
dragHandle.removeEventListener("dragend", handleDragEnd);
document.removeEventListener("pointerup", handlePointerUp, true);
document.removeEventListener("pointercancel", handlePointerCancel, 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 +852,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 (getPageRefDragSource(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 +876,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 +1501,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 External Content", {
source: "page-reference",
});
return;
}
return await callDefaultTextHandler(content);
}

// Check for block reference: ((uid))
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