169 lines
6 KiB
TypeScript
169 lines
6 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|