writekit/frontends/studio/src/components/editor/extensions/code-block/CodeBlockView.tsx

169 lines
6 KiB
TypeScript
Raw Normal View History

import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import type { NodeViewProps } from '@tiptap/react'
import { useState, useCallback, useRef, useEffect } from 'react'
import { getLanguageIconUrl, getLanguageDisplayName, SUPPORTED_LANGUAGES } from './icons'
import { Icons } from '../../../shared/Icons'
export function CodeBlockView({ node, updateAttributes, extension }: NodeViewProps) {
const { language, title } = node.attrs
const [showLanguageMenu, setShowLanguageMenu] = useState(false)
const [copied, setCopied] = useState(false)
const [isEditingTitle, setIsEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState(title || '')
const titleInputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const iconUrl = language ? getLanguageIconUrl(language) : null
const displayName = language ? getLanguageDisplayName(language) : 'Plain text'
const handleCopy = useCallback(async () => {
const code = node.textContent
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [node.textContent])
const handleLanguageSelect = useCallback((lang: string) => {
updateAttributes({ language: lang })
setShowLanguageMenu(false)
}, [updateAttributes])
const handleTitleSubmit = useCallback(() => {
updateAttributes({ title: titleValue || null })
setIsEditingTitle(false)
}, [titleValue, updateAttributes])
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleTitleSubmit()
}
if (e.key === 'Escape') {
setTitleValue(title || '')
setIsEditingTitle(false)
}
}, [handleTitleSubmit, title])
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus()
titleInputRef.current.select()
}
}, [isEditingTitle])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowLanguageMenu(false)
}
}
if (showLanguageMenu) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showLanguageMenu])
return (
<NodeViewWrapper className="code-block-wrapper relative my-6">
{/* Header bar */}
<div className="flex items-center gap-2 px-3 py-2 bg-zinc-50 border border-zinc-200 border-b-0 rounded-t-md">
{/* Language selector */}
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setShowLanguageMenu(!showLanguageMenu)}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{iconUrl && (
<img src={iconUrl} alt="" className="w-3.5 h-3.5" />
)}
<span>{displayName}</span>
<Icons.ChevronDown className="w-3 h-3 opacity-50" />
</button>
{showLanguageMenu && (
<div className="absolute top-full left-0 mt-1 w-48 max-h-64 overflow-y-auto bg-white border border-zinc-200 rounded-md shadow-lg z-50">
{SUPPORTED_LANGUAGES.map((lang) => {
const langIcon = getLanguageIconUrl(lang)
return (
<button
key={lang}
type="button"
onClick={() => handleLanguageSelect(lang)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-zinc-50 transition-colors ${
language === lang ? 'bg-zinc-100 text-zinc-900' : 'text-zinc-600'
}`}
>
{langIcon && <img src={langIcon} alt="" className="w-3.5 h-3.5" />}
<span>{getLanguageDisplayName(lang)}</span>
</button>
)
})}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-4 bg-zinc-200" />
{/* Title (editable) */}
<div className="flex-1 min-w-0">
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={handleTitleKeyDown}
placeholder="filename.ext"
className="w-full px-1 py-0.5 text-xs bg-white border border-zinc-300 rounded focus:outline-none focus:border-zinc-400"
contentEditable={false}
/>
) : (
<button
type="button"
onClick={() => {
setTitleValue(title || '')
setIsEditingTitle(true)
}}
className="text-xs text-zinc-500 hover:text-zinc-700 truncate max-w-full"
contentEditable={false}
>
{title || 'Add title...'}
</button>
)}
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 rounded transition-colors"
contentEditable={false}
>
{copied ? (
<>
<Icons.Check className="w-3 h-3 text-emerald-500" />
<span className="text-emerald-600">Copied</span>
</>
) : (
<>
<Icons.Copy className="w-3 h-3" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Code content */}
<pre className="!mt-0 !rounded-t-none">
<NodeViewContent as="code" className={language ? `language-${language}` : ''} />
</pre>
</NodeViewWrapper>
)
}