Skip to content
File

Blob: src/client/components/editor/extensions/callout/view.tsx

typescript94 lines
1import { FloatingPortal } from "@floating-ui/react";
2import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
3import { useCallback, useRef, useState } from "react";
4import { CalloutKindContent, getCalloutKindMeta } from "@/shared/editor/presentation/callout";
5import { preserveEditorSelectionOnMouseDown, useEditorPopover } from "../../controllers/menu/popover";
6import { useEditorAffordance } from "../../editor-affordance-context";
7import { CALLOUT_KINDS, normalizeCalloutKind, type CalloutKind } from "./kinds";
8 
9export 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}