diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index f7cc0171..7ce2707b 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -26,6 +26,11 @@ import { BlockProps } from "../Block"; import { useEntitySetContext } from "components/EntitySetProvider"; import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; +import { + useLinkPopoverStore, + scheduleLinkPopoverClose, + cancelLinkPopoverClose, +} from "components/LinkPopover"; export function useMountProsemirror({ props, @@ -142,21 +147,62 @@ export function useMountProsemirror({ window.open(url, "_blank", "noopener,noreferrer"); return; } - if (node.nodeSize - 2 <= _pos) return; - // Check for marks at the clicked position + if (node.nodeSize - 2 <= _pos) return; const nodeAt1 = node.nodeAt(_pos - 1); const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); - - // Check for link marks let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || nodeAt2?.marks.find((f) => f.type === schema.marks.link); if (linkMark) { - window.open(linkMark.attrs.href, "_blank", "noreferrer"); + let anchor = (_event.target as HTMLElement).closest("a") as HTMLElement | null; + if (anchor) { + cancelLinkPopoverClose(); + useLinkPopoverStore.getState().open( + linkMark.attrs.href, + anchor, + entityID, + ); + } return; } }, + handleDOMEvents: { + mouseover: (() => { + let activeTimer: number | null = null; + let activeAnchor: HTMLAnchorElement | null = null; + let activeLeaveHandler: (() => void) | null = null; + return (_view: EditorView, event: MouseEvent) => { + let target = event.target as HTMLElement; + let anchor = target.closest("a[href]") as HTMLAnchorElement | null; + if (!anchor) return false; + if (anchor === activeAnchor) return false; + if (activeTimer !== null) window.clearTimeout(activeTimer); + if (activeAnchor && activeLeaveHandler) { + activeAnchor.removeEventListener("mouseleave", activeLeaveHandler); + } + cancelLinkPopoverClose(); + activeAnchor = anchor; + activeTimer = window.setTimeout(() => { + useLinkPopoverStore.getState().open( + anchor.getAttribute("href") || "", + anchor, + entityID, + ); + activeTimer = null; + }, 300); + activeLeaveHandler = () => { + if (activeTimer !== null) window.clearTimeout(activeTimer); + activeTimer = null; + activeAnchor = null; + activeLeaveHandler = null; + scheduleLinkPopoverClose(); + }; + anchor.addEventListener("mouseleave", activeLeaveHandler, { once: true }); + return false; + }; + })(), + }, dispatchTransaction, }, ); diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 29b39dee..df1ca5e5 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -105,7 +105,7 @@ let baseSchema = { ], toDOM(node) { let { href } = node.attrs; - return ["a", { href, target: "_blank", referrerpolicy: "no-referrer" }, 0]; + return ["a", { href, target: "_blank", referrerpolicy: "no-referrer", style: "cursor: text" }, 0]; }, } as MarkSpec, }, diff --git a/components/LinkPopover.tsx b/components/LinkPopover.tsx new file mode 100644 index 00000000..969c0346 --- /dev/null +++ b/components/LinkPopover.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useMemo, useState, useCallback } from "react"; +import { create } from "zustand"; +import { Transaction } from "prosemirror-state"; +import * as RadixPopover from "@radix-ui/react-popover"; +import { schema } from "components/Blocks/TextBlock/schema"; +import { useEditorStates, setEditorState } from "src/state/useEditorState"; +import { useReplicache } from "src/replicache"; +import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; +import { DeleteTiny } from "components/Icons/DeleteTiny"; +import { CheckTiny } from "components/Icons/CheckTiny"; +import { PopoverArrow } from "components/Icons/PopoverArrow"; +import { theme } from "tailwind.config"; +import { ensureProtocol } from "src/utils/ensureProtocol"; +import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; + +type LinkPopoverState = { + href: string | null; + anchorElement: HTMLElement | null; + blockEntityID: string | null; + open: (href: string, anchor: HTMLElement, blockEntityID: string) => void; + close: () => void; +}; + +export const useLinkPopoverStore = create((set) => ({ + href: null, + anchorElement: null, + blockEntityID: null, + open: (href, anchor, blockEntityID) => + set({ href, anchorElement: anchor, blockEntityID }), + close: () => set({ href: null, anchorElement: null, blockEntityID: null }), +})); + +export function LinkPopover() { + let { href, anchorElement, blockEntityID, close } = useLinkPopoverStore(); + let { undoManager } = useReplicache(); + let [linkValue, setLinkValue] = useState(""); + + let isOpen = href !== null && anchorElement !== null; + let isDirty = href !== null && linkValue !== href; + + useEffect(() => { + if (href) { + setLinkValue(href); + } + }, [href]); + + // Close on scroll or resize, matching FootnotePopover behavior + useEffect(() => { + if (!isOpen || !anchorElement) return; + let handleScroll = () => close(); + let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); + scrollWrapper?.addEventListener("scroll", handleScroll); + window.addEventListener("resize", close); + return () => { + scrollWrapper?.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", close); + }; + }, [isOpen, anchorElement, close]); + + let applyLinkTransaction = useCallback( + (buildTr: (tr: Transaction, linkStart: number, linkEnd: number) => void) => { + if (!blockEntityID || !anchorElement) return; + let editorEntry = useEditorStates.getState().editorStates[blockEntityID]; + if (!editorEntry?.editor || !editorEntry.view) return; + + let editor = editorEntry.editor; + let from = editorEntry.view.posAtDOM(anchorElement, 0); + let to = editorEntry.view.posAtDOM( + anchorElement, + anchorElement.childNodes.length, + ); + let { start, end } = findMarkRange( + editor.doc, + schema.marks.link, + from, + to, + ); + + let tr = editor.tr; + buildTr(tr, start, end); + + let oldState = editor; + let newState = editor.apply(tr); + undoManager.add({ + undo: () => setEditorState(blockEntityID, { editor: oldState }), + redo: () => setEditorState(blockEntityID, { editor: newState }), + }); + setEditorState(blockEntityID, { editor: newState }); + close(); + }, + [blockEntityID, anchorElement, undoManager, close], + ); + + let saveLink = useCallback(() => { + applyLinkTransaction((tr, linkStart, linkEnd) => { + if (linkValue.trim() === "") { + tr.removeMark(linkStart, linkEnd, schema.marks.link); + } else { + let newHref = ensureProtocol(linkValue); + tr.addMark( + linkStart, + linkEnd, + schema.marks.link.create({ href: newHref }), + ); + } + }); + }, [applyLinkTransaction, linkValue]); + + let deleteLink = useCallback(() => { + applyLinkTransaction((tr, linkStart, linkEnd) => { + tr.removeMark(linkStart, linkEnd, schema.marks.link); + }); + }, [applyLinkTransaction]); + + let anchorRect = useMemo( + () => anchorElement?.getBoundingClientRect(), + [anchorElement], + ); + + return ( + + + + e.preventDefault()} + onMouseEnter={() => cancelLinkPopoverClose()} + onMouseLeave={() => scheduleLinkPopoverClose()} + className="link-popover z-50 bg-bg-page border border-border rounded-lg shadow-md px-2 py-1 w-[min(calc(100vw-24px),320px)]" + > +
+ setLinkValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + saveLink(); + } + if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }} + className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-primary placeholder:text-tertiary" + placeholder="https://example.com" + /> + {isDirty ? ( + + ) : ( +
+ + +
+ )} +
+ + + +
+
+
+ ); +} + +let closeTimer: number | null = null; + +export function scheduleLinkPopoverClose() { + cancelLinkPopoverClose(); + closeTimer = window.setTimeout(() => { + useLinkPopoverStore.getState().close(); + closeTimer = null; + }, 300); +} + +export function cancelLinkPopoverClose() { + if (closeTimer !== null) { + window.clearTimeout(closeTimer); + closeTimer = null; + } +} diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 30f74696..20bd9774 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -22,6 +22,7 @@ import { FootnoteContext } from "components/Footnotes/FootnoteContext"; import { FootnoteSection } from "components/Footnotes/FootnoteSection"; import { FootnoteSideColumn } from "components/Footnotes/FootnoteSideColumn"; import { FootnotePopover } from "components/Footnotes/FootnotePopover"; +import { LinkPopover } from "components/LinkPopover"; export function Page(props: { entityID: string; @@ -89,6 +90,7 @@ export function Page(props: { + ); diff --git a/components/Toolbar/InlineLinkToolbar.tsx b/components/Toolbar/InlineLinkToolbar.tsx index 6dd51c07..df33ca38 100644 --- a/components/Toolbar/InlineLinkToolbar.tsx +++ b/components/Toolbar/InlineLinkToolbar.tsx @@ -1,12 +1,13 @@ import { schema } from "components/Blocks/TextBlock/schema"; -import { EditorState, TextSelection } from "prosemirror-state"; +import { TextSelection } from "prosemirror-state"; import { useUIState } from "src/useUIState"; import { ToolbarButton } from "."; import { useEffect, useState } from "react"; import { Separator } from "components/Layout"; -import { MarkType } from "prosemirror-model"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; +import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; +import { ensureProtocol } from "src/utils/ensureProtocol"; import { Input } from "components/Input"; import { useReplicache } from "src/replicache"; import { CheckTiny } from "components/Icons/CheckTiny"; @@ -81,7 +82,11 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { start = from; end = to; } else { - let markRange = findMarkRange(focusedEditor.editor, schema.marks.link); + let markRange = findMarkRange( + focusedEditor.editor.doc, + schema.marks.link, + from, + ); start = markRange.start; end = markRange.end; } @@ -94,12 +99,7 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { } let [linkValue, setLinkValue] = useState(content); let setLink = () => { - let href = - !linkValue.startsWith("http") && - !linkValue.startsWith("mailto") && - !linkValue.startsWith("tel:") - ? `https://${linkValue}` - : linkValue; + let href = ensureProtocol(linkValue); let editor = focusedEditor?.editor; if (!editor || start === null || !end || !focusedBlock) return; @@ -176,32 +176,3 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { ); } -function findMarkRange(state: EditorState, markType: MarkType) { - const { from, $from } = state.selection; - - // Find the start of the mark - let start = from; - let startPos = $from; - while ( - startPos.parent.inlineContent && - startPos.nodeBefore && - startPos.nodeBefore.marks.some((mark) => mark.type === markType) - ) { - start -= startPos.nodeBefore.nodeSize; - startPos = state.doc.resolve(start); - } - - // Find the end of the mark - let end = from; - let endPos = $from; - while ( - endPos.parent.inlineContent && - endPos.nodeAfter && - endPos.nodeAfter.marks.some((mark) => mark.type === markType) - ) { - end += endPos.nodeAfter.nodeSize; - endPos = state.doc.resolve(end); - } - - return { start, end }; -} diff --git a/src/utils/ensureProtocol.ts b/src/utils/ensureProtocol.ts new file mode 100644 index 00000000..4be753c0 --- /dev/null +++ b/src/utils/ensureProtocol.ts @@ -0,0 +1,9 @@ +export function ensureProtocol(url: string) { + if ( + url.startsWith("http") || + url.startsWith("mailto") || + url.startsWith("tel:") + ) + return url; + return `https://${url}`; +} diff --git a/src/utils/prosemirror/findMarkRange.ts b/src/utils/prosemirror/findMarkRange.ts new file mode 100644 index 00000000..7c705cfe --- /dev/null +++ b/src/utils/prosemirror/findMarkRange.ts @@ -0,0 +1,28 @@ +import { MarkType, Node } from "prosemirror-model"; + +export function findMarkRange( + doc: Node, + markType: MarkType, + from: number, + to?: number, +) { + let start = from; + let end = to ?? from; + let $start = doc.resolve(start); + let $end = doc.resolve(end); + while ( + $start.nodeBefore && + $start.nodeBefore.marks.some((m) => m.type === markType) + ) { + start -= $start.nodeBefore.nodeSize; + $start = doc.resolve(start); + } + while ( + $end.nodeAfter && + $end.nodeAfter.marks.some((m) => m.type === markType) + ) { + end += $end.nodeAfter.nodeSize; + $end = doc.resolve(end); + } + return { start, end }; +}