sozsoft-platform/ui/src/components/codeLayout/ComponentCodeEditor.tsx

784 lines
25 KiB
TypeScript
Raw Normal View History

2026-02-24 20:44:16 +00:00
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'
2026-05-12 13:42:20 +00:00
import Notification from '../ui/Notification/Notification'
2026-02-24 20:44:16 +00:00
2026-05-12 13:42:20 +00:00
interface ComponentCodeEditorProps {
2026-02-24 20:44:16 +00:00
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
}
2026-05-12 13:42:20 +00:00
export const ComponentCodeEditor: React.FC<ComponentCodeEditorProps> = ({
2026-02-24 20:44:16 +00:00
code,
onChange,
onApplyCodeChanges,
2026-07-06 09:44:26 +00:00
onResetCodeChanges,
2026-02-24 20:44:16 +00:00
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)
2026-05-12 13:42:20 +00:00
// Refs to keep latest values accessible inside native DOM listeners
const localCodeRef = useRef(localCode)
const dropCallbacksRef = useRef({ onChange, onComponentAdded, onApplyCodeChanges })
2026-02-24 20:44:16 +00:00
useEffect(() => {
setLocalCode(code)
setHasChanges(false)
}, [code])
2026-05-12 13:42:20 +00:00
useEffect(() => {
localCodeRef.current = localCode
}, [localCode])
useEffect(() => {
dropCallbacksRef.current = { onChange, onComponentAdded, onApplyCodeChanges }
}, [onChange, onComponentAdded, onApplyCodeChanges])
2026-02-24 20:44:16 +00:00
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
}
2026-05-12 13:42:20 +00:00
/**
* 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--
2026-07-06 09:44:26 +00:00
if (parenDepth <= 0) {
insideReturn = false
break
}
2026-05-12 13:42:20 +00:00
}
}
}
} else {
for (const ch of line) {
if (ch === '(') parenDepth++
else if (ch === ')') {
parenDepth--
2026-07-06 09:44:26 +00:00
if (parenDepth <= 0) {
insideReturn = false
break
}
2026-05-12 13:42:20 +00:00
}
}
}
}
return insideReturn
}
2026-07-06 09:44:26 +00:00
const handleEditorCursorChange = () => {
2026-02-24 20:44:16 +00:00
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)
2026-05-12 13:42:20 +00:00
// 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)
2026-07-06 09:44:26 +00:00
if (d) {
componentDefData = d
break
}
2026-05-12 13:42:20 +00:00
}
if (!componentDefData) return
let componentDef: any
2026-07-06 09:44:26 +00:00
try {
componentDef = JSON.parse(componentDefData)
} catch {
return
}
2026-05-12 13:42:20 +00:00
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: 'top-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)
2026-07-06 09:44:26 +00:00
const {
onChange: onChg,
onComponentAdded: onAdded,
onApplyCodeChanges: onApply,
} = dropCallbacksRef.current
2026-05-12 13:42:20 +00:00
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
)
}
2026-02-24 20:44:16 +00:00
}
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)
2026-07-06 09:44:26 +00:00
onResetCodeChanges()
2026-02-24 20:44:16 +00:00
}
// 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"
2026-07-06 09:44:26 +00:00
onClick={handleFormatCode}
2026-02-24 20:44:16 +00:00
>
Formatla
</Button>
2026-02-24 20:44:16 +00:00
<Button
icon={<FaCog className="w-4 h-4" />}
variant={showSettings ? 'solid' : 'default'}
color="blue-600"
size="sm"
2026-07-06 09:44:26 +00:00
onClick={() => setShowSettings(!showSettings)}
2026-02-24 20:44:16 +00:00
>
Ayarlar
</Button>
2026-02-24 20:44:16 +00:00
<Button
icon={<FaTimes className="w-4 h-4" />}
variant="solid"
color="red-600"
size="sm"
2026-07-06 09:44:26 +00:00
onClick={handleResetChanges}
2026-02-24 20:44:16 +00:00
>
Sıfırla
</Button>
2026-02-24 20:44:16 +00:00
<Button
icon={<FaCheck className="w-4 h-4" />}
variant="solid"
color="green-600"
size="sm"
2026-07-06 09:44:26 +00:00
onClick={handleApplyChanges}
2026-02-24 20:44:16 +00:00
>
Uygula
</Button>
2026-02-24 20:44:16 +00:00
</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"
2026-07-06 09:44:26 +00:00
onClick={onComponentSave}
2026-02-24 20:44:16 +00:00
>
Kaydet
</Button>
2026-02-24 20:44:16 +00:00
</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"
2026-07-06 09:44:26 +00:00
onChange={(e) => setEditorTheme(e.target.value as 'vs-dark' | 'light')}
2026-02-24 20:44:16 +00:00
>
<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"
2026-07-06 09:44:26 +00:00
onChange={(e) => setFontSize(parseInt(e.target.value))}
2026-02-24 20:44:16 +00:00
/>
</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"
2026-07-06 09:44:26 +00:00
onChange={(e) => setWordWrap(e.target.value as 'on' | 'off')}
2026-02-24 20:44:16 +00:00
>
<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"
2026-07-06 09:44:26 +00:00
onClick={() => setMinimap(!minimap)}
2026-02-24 20:44:16 +00:00
>
{minimap ? 'Etkin' : 'Devre Dışı'}
</Button>
2026-02-24 20:44:16 +00:00
</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,
}}
2026-07-06 09:44:26 +00:00
onChange={handleCodeChange}
onMount={handleEditorDidMount}
2026-02-24 20:44:16 +00:00
/>
</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>
)
}