sozsoft-platform/ui/src/components/codeLayout/ComponentCodeEditor.tsx
Sedat ÖZTÜRK 04e88fc969 bottom-end
2026-07-08 09:14:40 +03:00

783 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react'
import Editor from '@monaco-editor/react'
import { ComponentDefinition } from '../../proxy/developerKit/componentInfo'
import { generateSingleComponentJSX, generateUniqueId } from '@/utils/codeParser'
import { FaCheck, FaCode, FaMousePointer, FaSave, FaCog, FaTimes } from 'react-icons/fa'
import { Button, toast } from '../ui'
import Notification from '../ui/Notification/Notification'
interface ComponentCodeEditorProps {
code: string
onChange: (code: string) => void
onApplyCodeChanges: (code: string) => void
onResetCodeChanges: () => void
language?: string
theme?: 'vs-dark' | 'light'
onCursorChange?: (componentId: string | null) => void
onComponentAdded?: (componentDef: ComponentDefinition) => void
onComponentSave: () => void
}
export const ComponentCodeEditor: React.FC<ComponentCodeEditorProps> = ({
code,
onChange,
onApplyCodeChanges,
onResetCodeChanges,
language = 'typescript',
theme = 'vs-dark',
onCursorChange,
onComponentAdded,
onComponentSave,
}) => {
const [localCode, setLocalCode] = useState(code)
const [hasChanges, setHasChanges] = useState(false)
const [isFormatting, setIsFormatting] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [editorTheme, setEditorTheme] = useState(theme)
const [fontSize, setFontSize] = useState(14)
const [wordWrap, setWordWrap] = useState<'on' | 'off'>('on')
const [minimap, setMinimap] = useState(true)
const [showSuccessMessage, setShowSuccessMessage] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const [dropIndicator, setDropIndicator] = useState<{
show: boolean
line: number
column: number
}>({ show: false, line: 0, column: 0 })
const editorRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Refs to keep latest values accessible inside native DOM listeners
const localCodeRef = useRef(localCode)
const dropCallbacksRef = useRef({ onChange, onComponentAdded, onApplyCodeChanges })
useEffect(() => {
setLocalCode(code)
setHasChanges(false)
}, [code])
useEffect(() => {
localCodeRef.current = localCode
}, [localCode])
useEffect(() => {
dropCallbacksRef.current = { onChange, onComponentAdded, onApplyCodeChanges }
}, [onChange, onComponentAdded, onApplyCodeChanges])
const findComponentIdAtPosition = (
code: string,
position: { lineNumber: number; column: number },
): string | null => {
const lines = code.split('\n')
const currentLineIndex = position.lineNumber - 1
// Önce bulunduğu satırı kontrol et
const line = lines[currentLineIndex] || ''
const idMatch = line.match(/id=["']([\w-]+)["']/)
if (idMatch) return idMatch[1]
// Eğer bulunduğu satırda yoksa, yakındaki satırları kontrol et
// Önce yukarı doğru ara, sonra aşağı doğru ara
for (let distance = 1; distance <= 3; distance++) {
// Yukarı ara
if (currentLineIndex - distance >= 0) {
const upLine = lines[currentLineIndex - distance] || ''
// Component tag başlangıcını kontrol et
const componentMatch = upLine.match(/<([A-Z][a-zA-Z]*)/)
if (componentMatch) {
// Bu component tag'inin ID'sini ara (aynı satırda veya sonraki satırlarda)
for (
let j = currentLineIndex - distance;
j <= Math.min(lines.length - 1, currentLineIndex + 2);
j++
) {
const searchLine = lines[j] || ''
const foundId = searchLine.match(/id=["']([\w-]+)["']/)
if (foundId) return foundId[1]
}
}
}
// Aşağı ara
if (currentLineIndex + distance < lines.length) {
const downLine = lines[currentLineIndex + distance] || ''
const componentMatch = downLine.match(/<([A-Z][a-zA-Z]*)/)
if (componentMatch) {
// Bu component tag'inin ID'sini ara
for (
let j = currentLineIndex + distance;
j <= Math.min(lines.length - 1, currentLineIndex + distance + 2);
j++
) {
const searchLine = lines[j] || ''
const foundId = searchLine.match(/id=["']([\w-]+)["']/)
if (foundId) return foundId[1]
}
}
}
}
// Hiçbir şey bulunamadıysa, cursor'ın bulunduğu satırda herhangi bir JSX tag var mı kontrol et
const jsxMatch = line.match(/<\/?([A-Z][a-zA-Z]*)/)
if (jsxMatch) {
// Bu tag'e ait ID'yi bulabilir miyiz?
const componentName = jsxMatch[1]
for (
let i = Math.max(0, currentLineIndex - 5);
i <= Math.min(lines.length - 1, currentLineIndex + 5);
i++
) {
const searchLine = lines[i] || ''
if (searchLine.includes(`<${componentName}`) && searchLine.includes('id=')) {
const foundId = searchLine.match(/id=["']([\w-]+)["']/)
if (foundId) return foundId[1]
}
}
}
return null
}
/**
* Returns true if the given 1-based lineNumber is inside a `return (...)` block.
* Uses parenthesis depth counting to track entry and exit of the return expression.
*/
const isPositionInsideJSXReturn = (codeStr: string, lineNumber: number): boolean => {
const lines = codeStr.split('\n')
let insideReturn = false
let parenDepth = 0
for (let i = 0; i < lineNumber && i < lines.length; i++) {
const line = lines[i]
if (!insideReturn) {
if (/\breturn\s*\(/.test(line)) {
insideReturn = true
parenDepth = 0
const returnIdx = line.search(/\breturn/)
const fromReturn = line.slice(returnIdx)
for (const ch of fromReturn) {
if (ch === '(') parenDepth++
else if (ch === ')') {
parenDepth--
if (parenDepth <= 0) {
insideReturn = false
break
}
}
}
}
} else {
for (const ch of line) {
if (ch === '(') parenDepth++
else if (ch === ')') {
parenDepth--
if (parenDepth <= 0) {
insideReturn = false
break
}
}
}
}
}
return insideReturn
}
const handleEditorCursorChange = () => {
if (!editorRef.current) return
const position = editorRef.current.getPosition()
if (!position) return
const id = findComponentIdAtPosition(localCode, position)
if (onCursorChange) onCursorChange(id)
}
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.Latest,
allowNonTsExtensions: true,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
module: monaco.languages.typescript.ModuleKind.CommonJS,
noEmit: true,
esModuleInterop: true,
jsx: monaco.languages.typescript.JsxEmit.React,
reactNamespace: 'React',
allowJs: true,
typeRoots: ['node_modules/@types'],
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleApplyChanges()
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, () => {
handleFormatCode()
})
editor.onDidChangeCursorPosition(handleEditorCursorChange)
// Intercept Monaco's native drop so it never inserts raw JSON text.
// We handle the drop ourselves: insert JSX when inside return(), skip otherwise.
const domNode = editor.getDomNode()
if (domNode) {
domNode.addEventListener(
'drop',
(nativeEvent: DragEvent) => {
nativeEvent.preventDefault()
nativeEvent.stopPropagation()
nativeEvent.stopImmediatePropagation()
if (!nativeEvent.dataTransfer) return
// Read component definition from drag data
let componentDefData: string | undefined
for (const fmt of ['application/json', 'text/plain']) {
const d = nativeEvent.dataTransfer.getData(fmt)
if (d) {
componentDefData = d
break
}
}
if (!componentDefData) return
let componentDef: any
try {
componentDef = JSON.parse(componentDefData)
} catch {
return
}
if (!componentDef?.name) return
// Resolve the drop position inside Monaco
const dropPos = editor.getTargetAtClientPoint(nativeEvent.clientX, nativeEvent.clientY)
const position = dropPos?.position ?? editor.getPosition()
if (!position) return
// Only allow drop inside JSX return(...) block
if (!isPositionInsideJSXReturn(localCodeRef.current, position.lineNumber)) {
toast.push(
<Notification type="warning" duration={3000}>
Bileşen yalnızca <strong>return(...)</strong> bloğu içine eklenebilir.
</Notification>,
{ placement: 'bottom-end' },
)
return
}
const componentId = generateUniqueId()
const adaptedProps = {
...(componentDef.properties || []).reduce(
(acc: any, prop: any) => {
acc[prop.name] = {
type: prop.type,
value: prop.value,
...(prop.options ? { options: prop.options } : {}),
}
return acc
},
{} as Record<string, any>,
),
id: { type: 'string', value: componentId },
}
const componentJSX = generateSingleComponentJSX(componentDef.name, adaptedProps)
const componentDefWithId = {
...componentDef,
id: componentId,
properties: [
...(componentDef.properties || []).filter((p: any) => p.name !== 'id'),
{ name: 'id', type: 'string', value: componentId, category: 'properties' },
],
}
const lines = localCodeRef.current.split('\n')
const indent = (lines[position.lineNumber - 1] || '').match(/^(\s*)/)?.[1] ?? ' '
const formattedJSX = `${indent}${componentJSX}`
const newLines = [...lines]
newLines.splice(position.lineNumber, 0, formattedJSX)
const newCode = newLines.join('\n')
setLocalCode(newCode)
const {
onChange: onChg,
onComponentAdded: onAdded,
onApplyCodeChanges: onApply,
} = dropCallbacksRef.current
onChg(newCode)
if (onAdded) onAdded(componentDefWithId)
setTimeout(() => onApply(newCode), 100)
setIsDragOver(false)
setDropIndicator({ show: false, line: 0, column: 0 })
},
true, // capture phase — fires before Monaco's own listeners
)
}
}
const handleCodeChange = (value: string | undefined) => {
if (value !== undefined) {
setLocalCode(value)
setHasChanges(value !== code)
onChange(value)
}
}
const handleApplyChanges = () => {
onApplyCodeChanges(localCode)
setHasChanges(false)
setShowSuccessMessage(true)
handleFormatCode()
setTimeout(() => setShowSuccessMessage(false), 3000)
}
const handleFormatCode = async () => {
if (editorRef.current) {
setIsFormatting(true)
try {
await editorRef.current.getAction('editor.action.formatDocument').run()
} catch (error) {
console.error('Kod formatlama hatası:', error)
} finally {
setIsFormatting(false)
}
}
}
const handleResetChanges = () => {
setLocalCode(code)
setHasChanges(false)
onResetCodeChanges()
}
// Drag & Drop handlers for Code Editor
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!editorRef.current || !containerRef.current) return
try {
const hasComponentData = e.dataTransfer.types.some(
(type) => type === 'application/json' || type === 'text/plain' || type === 'text',
)
if (hasComponentData) {
e.dataTransfer.dropEffect = 'copy'
setIsDragOver(true)
// Get cursor position from mouse coordinates
const rect = containerRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// Convert to editor position
const position = editorRef.current.getTargetAtClientPoint(x, y)
if (position) {
setDropIndicator({
show: true,
line: position.position.lineNumber,
column: position.position.column,
})
}
}
} catch (err) {
console.warn('Error handling drag over:', err)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsDragOver(false)
setDropIndicator({ show: false, line: 0, column: 0 })
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragOver(false)
setDropIndicator({ show: false, line: 0, column: 0 })
if (!editorRef.current || !containerRef.current) return
try {
let componentDefData
const formats = ['application/json', 'text/plain', 'text']
// Sürüklenen bileşenin verisini al
for (const format of formats) {
const data = e.dataTransfer.getData(format)
if (data) {
componentDefData = data
break
}
}
if (!componentDefData) return
let componentDef
try {
componentDef = JSON.parse(componentDefData)
} catch (error) {
console.error('Component verisi çözümlenemedi:', error)
return
}
// 1. Component ID'sini oluştur
const componentId = generateUniqueId()
// 2. Props'ları adapt et ve ID'yi dahil et
const adaptedProps = {
...(componentDef.properties || []).reduce(
(acc: any, prop: any) => {
acc[prop.name] = {
type: prop.type,
value: prop.value,
...(prop.options ? { options: prop.options } : {}),
}
return acc
},
{} as Record<string, any>,
),
id: {
type: 'string',
value: componentId,
},
}
// 3. JSX oluştur
const componentJSX = generateSingleComponentJSX(componentDef.name, adaptedProps)
// 4. Component tanımını ID ile genişlet
const componentDefWithId = {
...componentDef,
id: componentId,
properties: [
...(componentDef.properties || []).filter((p: any) => p.name !== 'id'),
{
name: 'id',
type: 'string',
value: componentId,
category: 'properties',
},
],
}
// 5. Pozisyonu al
let position = editorRef.current.getPosition()
if (!position) {
console.error('Geçersiz pozisyon tespit edildi. Yedek pozisyon kullanılıyor.')
position = { lineNumber: 0, column: 0 }
}
// 6. JSX kodunu pozisyonda ekle
const newCode = insertJSXAtPosition(localCode, componentJSX, position)
setLocalCode(newCode)
onChange(newCode)
// 7. Parent'a bildirim gönder
if (onComponentAdded) {
onComponentAdded(componentDefWithId)
}
// 8. Uygulamayı tetikle
setTimeout(() => {
onApplyCodeChanges(newCode)
}, 100)
} catch (error) {
console.error('Bileşen bırakılırken hata oluştu:', error)
}
}
const insertJSXAtPosition = (
code: string,
jsx: string,
position: { lineNumber: number; column: number },
): string => {
const updatedCode = code
const lines = updatedCode.split('\n')
// Pozisyonu doğrula (import eklendiyse satır numaraları değişmiş olabilir)
if (position.lineNumber < 1 || position.lineNumber > lines.length) {
console.error('Geçersiz satır numarası:', position.lineNumber)
position.lineNumber = lines.length // Geçersiz satırda son satıra ekle
}
const line = lines[position.lineNumber - 1] // 1 tabanlı indeks, 0 tabanlıya çevrilir
if (!line) {
console.error('Satır bulunamadı:', position.lineNumber)
return updatedCode // Eğer satır yoksa, kodu değiştirmeden döndür
}
// Satırın indentasyonunu belirle
const indentMatch = line.match(/^(\s*)/)
const indent = indentMatch ? indentMatch[1] : ' ' // Default 6 boşluk
// Component'i yeni satıra ekle (mevcut satırın altına)
const formattedJSX = `${indent}${jsx}`
lines.splice(position.lineNumber, 0, formattedJSX)
return lines.join('\n') // Tüm satırları birleştir ve yeni kodu döndür
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
return (
<div
ref={containerRef}
className={`w-full h-full flex flex-col min-h-0 max-h-full bg-gray-900 text-white relative ${
isDragOver ? 'ring-2 ring-blue-500 ring-offset-2' : ''
}`}
style={{ flex: 1, minHeight: 0, maxHeight: '100vh' }}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drop Indicator */}
{dropIndicator.show && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-10 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50 pointer-events-none">
<div className="bg-blue-500 text-white px-4 py-2 rounded-lg font-medium shadow-lg">
Bileşeni Line {dropIndicator.line}, Column {dropIndicator.column} konumuna bırak
</div>
</div>
)}
{/* Header */}
<div className="bg-gray-800 border-b border-gray-700 p-4 flex items-end justify-between shrink-0">
<div className="flex items-center gap-2">
<Button
loading={isFormatting}
icon={<FaCode className="w-4 h-4" />}
variant="default"
size="sm"
onClick={handleFormatCode}
>
Formatla
</Button>
<Button
icon={<FaCog className="w-4 h-4" />}
variant={showSettings ? 'solid' : 'default'}
color="blue-600"
size="sm"
onClick={() => setShowSettings(!showSettings)}
>
Ayarlar
</Button>
<Button
icon={<FaTimes className="w-4 h-4" />}
variant="solid"
color="red-600"
size="sm"
onClick={handleResetChanges}
>
Sıfırla
</Button>
<Button
icon={<FaCheck className="w-4 h-4" />}
variant="solid"
color="green-600"
size="sm"
onClick={handleApplyChanges}
>
Uygula
</Button>
</div>
<div className="col-span-2 flex items-center justify-end">
<Button
icon={<FaSave className="w-4 h-4" />}
variant="solid"
color="yellow-600"
size="sm"
className="shadow-sm"
onClick={onComponentSave}
>
Kaydet
</Button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="bg-gray-800 border-b border-gray-700 p-4 shrink-0">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Tema</label>
<select
value={editorTheme}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setEditorTheme(e.target.value as 'vs-dark' | 'light')}
>
<option value="vs-dark">Koyu</option>
<option value="light">ık</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Font Boyutu</label>
<input
type="number"
min="10"
max="24"
value={fontSize}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setFontSize(parseInt(e.target.value))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Kelime Kaydırma
</label>
<select
value={wordWrap}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => setWordWrap(e.target.value as 'on' | 'off')}
>
<option value="on">ık</option>
<option value="off">Kapalı</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Mini Harita</label>
<Button
block
variant={minimap ? 'solid' : 'default'}
color="blue-600"
size="sm"
onClick={() => setMinimap(!minimap)}
>
{minimap ? 'Etkin' : 'Devre Dışı'}
</Button>
</div>
</div>
</div>
)}
{/* Editor */}
<div className="flex-1 h-full">
<Editor
height="100%"
width="100%"
language={language}
theme={editorTheme}
value={localCode}
options={{
fontSize: fontSize,
wordWrap: wordWrap,
minimap: { enabled: minimap },
automaticLayout: true,
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
guides: {
bracketPairs: true,
indentation: true,
},
suggest: {
showKeywords: true,
showSnippets: true,
showFunctions: true,
showConstructors: true,
showFields: true,
showVariables: true,
showClasses: true,
showStructs: true,
showInterfaces: true,
showModules: true,
showProperties: true,
showEvents: true,
showOperators: true,
showUnits: true,
showValues: true,
showConstants: true,
showEnums: true,
showEnumMembers: true,
showColors: true,
showFiles: true,
showReferences: true,
showFolders: true,
showTypeParameters: true,
showUsers: true,
showIssues: true,
},
quickSuggestions: {
other: true,
comments: true,
strings: true,
},
parameterHints: {
enabled: true,
},
hover: {
enabled: true,
},
contextmenu: true,
mouseWheelZoom: true,
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
folding: true,
foldingStrategy: 'indentation',
showFoldingControls: 'always',
unfoldOnClickAfterEndOfLine: false,
tabSize: 2,
insertSpaces: true,
detectIndentation: true,
trimAutoWhitespace: true,
formatOnPaste: true,
formatOnType: true,
}}
onChange={handleCodeChange}
onMount={handleEditorDidMount}
/>
</div>
{/* Status Bar */}
<div className="bg-gray-800 border-t border-gray-700 px-4 py-2 flex items-center justify-between text-sm text-gray-400 shrink-0">
<div className="flex items-center gap-4">
<span>TypeScript React</span>
<span>UTF-8</span>
<span>LF</span>
</div>
<div className="flex items-center gap-4">
<span>Satır 1, Sütun 1</span>
<span>Boşluklar: 2</span>
{hasChanges && <span className="text-orange-400"> Kaydedilmemiş değişiklikler</span>}
{showSuccessMessage && (
<span className="text-green-400 flex items-center gap-1">
<FaCheck className="w-4 h-4" />
</span>
)}
{isDragOver && (
<span className="text-blue-400 flex items-center gap-1">
<FaMousePointer className="w-4 h-4" />
Bileşeni bırakmaya hazır
</span>
)}
</div>
</div>
</div>
)
}