File
Blob: src/client/components/editor/extensions/callout/view.tsx
| 1 | import { FloatingPortal } from "@floating-ui/react"; |
| 2 | import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; |
| 3 | import { useCallback, useRef, useState } from "react"; |
| 4 | import { CalloutKindContent, getCalloutKindMeta } from "@/shared/editor/presentation/callout"; |
| 5 | import { preserveEditorSelectionOnMouseDown, useEditorPopover } from "../../controllers/menu/popover"; |
| 6 | import { useEditorAffordance } from "../../editor-affordance-context"; |
| 7 | import { CALLOUT_KINDS, normalizeCalloutKind, type CalloutKind } from "./kinds"; |
| 8 | |
| 9 | export function CalloutView({ node, updateAttributes }: NodeViewProps) { |
| 10 | const { documentEditable } = useEditorAffordance(); |
| 11 | const [open, setOpen] = useState(false); |
| 12 | const btnRef = useRef<HTMLButtonElement>(null); |
| 13 | |
| 14 | const kind = normalizeCalloutKind(node.attrs.kind); |
| 15 | const { label } = getCalloutKindMeta(kind); |
| 16 | |
| 17 | const { floatingStyles, getFloatingProps, refs } = useEditorPopover({ |
| 18 | open, |
| 19 | onClose: () => setOpen(false), |
| 20 | anchorRef: btnRef, |
| 21 | // Open above the pill so the callout body stays readable while the user |
| 22 | // changes kind; flip middleware falls back to bottom-start near viewport top. |
| 23 | placement: "top-start", |
| 24 | offset: 6, |
| 25 | padding: 8, |
| 26 | }); |
| 27 | |
| 28 | const selectKind = useCallback( |
| 29 | (next: CalloutKind) => { |
| 30 | updateAttributes({ kind: next }); |
| 31 | setOpen(false); |
| 32 | }, |
| 33 | [updateAttributes], |
| 34 | ); |
| 35 | |
| 36 | return ( |
| 37 | <NodeViewWrapper className="tiptap-callout" data-callout="" data-callout-kind={kind}> |
| 38 | <button |
| 39 | ref={btnRef} |
| 40 | type="button" |
| 41 | className="tiptap-callout-kind-btn" |
| 42 | onClick={() => documentEditable && setOpen((prev) => !prev)} |
| 43 | onMouseDown={(e) => e.preventDefault()} |
| 44 | contentEditable={false} |
| 45 | aria-haspopup={documentEditable ? "menu" : undefined} |
| 46 | aria-expanded={documentEditable ? open : undefined} |
| 47 | aria-label={documentEditable ? `Callout kind: ${label}. Click to change.` : `Callout: ${label}`} |
| 48 | disabled={!documentEditable} |
| 49 | > |
| 50 | <CalloutKindContent kind={kind} /> |
| 51 | </button> |
| 52 | |
| 53 | {documentEditable && open && ( |
| 54 | <FloatingPortal> |
| 55 | <div |
| 56 | ref={(el) => { |
| 57 | refs.setFloating(el); |
| 58 | }} |
| 59 | role="menu" |
| 60 | aria-label="Callout kind" |
| 61 | className="tiptap-menu-surface tiptap-callout-kind-menu" |
| 62 | style={{ ...floatingStyles, zIndex: 50, animation: "var(--animate-menu)" }} |
| 63 | {...getFloatingProps({ |
| 64 | onMouseDownCapture: (e) => preserveEditorSelectionOnMouseDown(e), |
| 65 | })} |
| 66 | > |
| 67 | {CALLOUT_KINDS.map((candidate) => { |
| 68 | const meta = getCalloutKindMeta(candidate); |
| 69 | const active = candidate === kind; |
| 70 | return ( |
| 71 | <button |
| 72 | key={candidate} |
| 73 | type="button" |
| 74 | role="menuitemradio" |
| 75 | aria-checked={active} |
| 76 | className={`tiptap-menu-item tiptap-callout-kind-item${active ? " is-active" : ""}`} |
| 77 | data-callout-kind={candidate} |
| 78 | onMouseDown={(e) => e.preventDefault()} |
| 79 | onClick={() => selectKind(candidate)} |
| 80 | > |
| 81 | <meta.Icon size={14} aria-hidden /> |
| 82 | <span>{meta.label}</span> |
| 83 | </button> |
| 84 | ); |
| 85 | })} |
| 86 | </div> |
| 87 | </FloatingPortal> |
| 88 | )} |
| 89 | |
| 90 | <NodeViewContent className="tiptap-callout-body" /> |
| 91 | </NodeViewWrapper> |
| 92 | ); |
| 93 | } |