diff --git a/apps/roam/src/components/canvas/Tldraw.tsx b/apps/roam/src/components/canvas/Tldraw.tsx index eb102d5f1..d9602388f 100644 --- a/apps/roam/src/components/canvas/Tldraw.tsx +++ b/apps/roam/src/components/canvas/Tldraw.tsx @@ -37,7 +37,6 @@ import { TLAssetId, getHashForString, TLShapeId, - TLShape, TLStore, TLStoreWithStatus, useToasts, @@ -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, @@ -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 @@ -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(".rm-page-ref"); + const pageRefContainer = target.closest("[data-link-title]"); + const element = pageRef || pageRefContainer; + if (!element) return null; + + const pageTitle = + pageRef?.getAttribute("data-tag") || + pageRef?.getAttribute("data-link-title") || + pageRef + ?.closest("[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(); + 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(".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, + }); + dragHandle.addEventListener("pointerdown", handleDragHandlePointerDown); + dragHandle.addEventListener("mousedown", handleDragHandlePointerDown); + 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("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 @@ -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) => { @@ -658,7 +876,23 @@ const TldrawCanvasShared = ({ const handleDrop = (e: React.DragEvent) => { 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"); @@ -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 => { + 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)) diff --git a/apps/roam/src/components/results-view/Kanban.tsx b/apps/roam/src/components/results-view/Kanban.tsx index 9ff747423..277675011 100644 --- a/apps/roam/src/components/results-view/Kanban.tsx +++ b/apps/roam/src/components/results-view/Kanban.tsx @@ -141,6 +141,8 @@ const KanbanCard = (card: { ) : cardView.mode === "link" ? (