Skip to content
File

Blob: src/client/components/editor/extensions/code-block/view.tsx

typescript132 lines
1import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
2import { FloatingPortal } from "@floating-ui/react";
3import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
4import type { NodeViewProps } from "@tiptap/react";
5import { CODE_LANGUAGES, resolveLanguage } from "@/shared/editor/schema/code-block";
6import {
7 CodeBlockCopyButtonChrome,
8 getCodeCopyButtonLabel,
9 getCodeLanguageLabel,
10} from "@/shared/editor/presentation/code-block";
11import { preserveEditorSelectionOnMouseDown, useEditorPopover } from "../../controllers/menu/popover";
12import { useEditorAffordance } from "../../editor-affordance-context";
13import { useCopyFeedback } from "@/shared/browser/use-copy-feedback";
14import "@/styles/editor/code-block.css";
15 
16export function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
17 const { documentEditable } = useEditorAffordance();
18 const [open, setOpen] = useState(false);
19 const btnRef = useRef<HTMLButtonElement>(null);
20 const dropdownRef = useRef<HTMLDivElement>(null);
21 const activeItemRef = useRef<HTMLButtonElement>(null);
22 const copyId = useId();
23 const { copiedId, copy } = useCopyFeedback<string>();
24 const isCopied = copiedId === copyId;
25 
26 const language = resolveLanguage(node.attrs.language);
27 const displayName = getCodeLanguageLabel(language);
28 const { floatingStyles, getFloatingProps, refs } = useEditorPopover({
29 open,
30 onClose: () => setOpen(false),
31 anchorRef: btnRef,
32 placement: "bottom-end",
33 offset: 4,
34 padding: 8,
35 });
36 
37 const selectLanguage = useCallback(
38 (lang: string) => {
39 updateAttributes({ language: lang });
40 setOpen(false);
41 },
42 [updateAttributes],
43 );
44 
45 useLayoutEffect(() => {
46 if (!open) return;
47 
48 let frameId = 0;
49 
50 frameId = window.requestAnimationFrame(() => {
51 const dropdown = dropdownRef.current;
52 const activeItem = activeItemRef.current;
53 if (!dropdown || !activeItem) return;
54 
55 const maxScrollTop = Math.max(0, dropdown.scrollHeight - dropdown.clientHeight);
56 const targetScrollTop = activeItem.offsetTop - (dropdown.clientHeight - activeItem.offsetHeight) / 2;
57 
58 dropdown.scrollTop = Math.min(maxScrollTop, Math.max(0, targetScrollTop));
59 });
60 
61 return () => window.cancelAnimationFrame(frameId);
62 }, [language, open]);
63 
64 const controls = (
65 <div className="tiptap-code-block-controls" contentEditable={false}>
66 <CodeBlockCopyButtonChrome
67 copied={isCopied}
68 onClick={() => copy(copyId, node.textContent)}
69 onMouseDown={(e) => e.preventDefault()}
70 aria-label={getCodeCopyButtonLabel(isCopied)}
71 />
72 <button
73 ref={btnRef}
74 type="button"
75 className="tiptap-code-block-lang-btn"
76 onClick={() => documentEditable && setOpen((p) => !p)}
77 onMouseDown={(e) => e.preventDefault()}
78 aria-label={documentEditable ? `Language: ${displayName}. Click to change.` : `Language: ${displayName}`}
79 aria-expanded={documentEditable ? open : undefined}
80 aria-haspopup={documentEditable ? "menu" : undefined}
81 disabled={!documentEditable}
82 >
83 {displayName}
84 </button>
85 </div>
86 );
87 
88 return (
89 <NodeViewWrapper className="tiptap-code-block-wrapper" data-language={language}>
90 {controls}
91
92 {open && (
93 <FloatingPortal>
94 <div
95 ref={(node) => {
96 refs.setFloating(node);
97 }}
98 className="tiptap-menu-surface"
99 role="menu"
100 aria-label="Code block language"
101 style={{ ...floatingStyles, width: 160, zIndex: 50, animation: "var(--animate-menu)" }}
102 {...getFloatingProps({
103 onMouseDownCapture: (e) => preserveEditorSelectionOnMouseDown(e),
104 })}
105 >
106 <div ref={dropdownRef} className="tiptap-code-block-lang-dropdown">
107 {Object.entries(CODE_LANGUAGES).map(([id, meta]) => (
108 <button
109 key={id}
110 ref={id === language ? activeItemRef : null}
111 type="button"
112 className={`tiptap-menu-item tiptap-code-block-lang-item${id === language ? " is-active" : ""}`}
113 onMouseDown={(e) => e.preventDefault()}
114 onClick={() => selectLanguage(id)}
115 role="menuitemradio"
116 aria-checked={id === language}
117 >
118 {meta.name}
119 </button>
120 ))}
121 </div>
122 </div>
123 </FloatingPortal>
124 )}
125
126 <pre className="tiptap-code-block-pre" spellCheck={false}>
127 <NodeViewContent<"code"> as="code" className="tiptap-code-block-content" style={{ whiteSpace: "inherit" }} />
128 </pre>
129 </NodeViewWrapper>
130 );
131}