import React, { useState, useCallback, useEffect } from 'react'; import { HiCodeBracket, HiExclamationTriangle, HiGlobeAlt, HiArrowDownTray, HiClock, HiArrowsPointingOut } from 'react-icons/hi2'; import { Card } from '../../../ui/Card'; import { Button } from '../../../ui/Button'; import Typography from '../../../ui/Typography'; import Modal from '../../../ui/Modal'; import { useBlockTesterStore } from '../../../../stores/blockTesterStore'; import { useConfigStore } from '../../../../stores/configStore'; import { cn } from '../../../../lib/utils'; import { debounce } from '../../../../utils/block-tester'; import type { ErrorLog, TemplateSource } from '../../../../types/block-tester.types'; import BlockTesterErrorBoundary from '../shared/ErrorBoundary'; import * as Handlebars from 'handlebars'; import { BlockLoader, Logger, type NetworkType } from '@hashgraphonline/standards-sdk'; import MonacoEditor, { loader } from '@monaco-editor/react'; import * as monaco from 'monaco-editor'; loader.config({ monaco }); interface TemplateEditorProps { template: string; onChange: (template: string) => void; errors?: ErrorLog[]; className?: string; templateSource?: TemplateSource; } type TemplateSourceType = 'inline' | 'hcs'; interface HCSTemplate { template: string; definition?: Record; topicId: string; lastFetched: Date; cached: boolean; } interface TemplateExample { name: string; template: string; attributes: Record; } /** * Template editor component with Monaco Editor integration and developer-friendly interface * Features syntax highlighting for HTML and Handlebars templates with clear guidance for new users */ const TemplateEditor: React.FC = ({ template, onChange, errors = [], className, templateSource: templateSourceProp }) => { const { config } = useConfigStore(); const isDarkMode = config?.advanced?.theme === 'dark'; const [isEditorReady, setIsEditorReady] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [templateSource, setTemplateSource] = useState('inline'); const [topicId, setTopicId] = useState('0.0.6617393'); const [networkType, setNetworkType] = useState('testnet'); const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); const [hcsTemplate, setHCSTemplate] = useState(null); const [loadError, setLoadError] = useState(null); const debouncedOnChange = useCallback( debounce((value: string) => { onChange(value || ''); }, 300), [onChange] ); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isFullscreen && e.key === 'Escape') { setIsFullscreen(false); } }; if (isFullscreen) { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); } }, [isFullscreen]); const DEFAULT_STARTER_TEMPLATE = `

{{title}}

{{subtitle}}

{{content}}

`; const templateExamples: TemplateExample[] = [ { name: 'Starter Template', template: DEFAULT_STARTER_TEMPLATE, attributes: { title: 'Welcome to Block Builder', subtitle: 'Build amazing components', content: 'This is your first block template. Edit the template above to customize how your block looks and works.', buttonText: 'Get Started' } }, { name: 'Simple Card', template: `

{{title}}

{{description}}

`, attributes: { title: 'Card Title', description: 'Your card description goes here' } }, { name: 'Alert Message', template: `

{{title}}

{{message}}

`, attributes: { title: 'Info', message: 'This is an important message for your users' } } ]; const validateTopicId = (id: string): boolean => { const topicIdPattern = /^0\.0\.\d+$/; return topicIdPattern.test(id); }; const loadHCSTemplate = useCallback(async (topicIdToLoad: string, forceRefresh: boolean = false) => { if (!topicIdToLoad || !validateTopicId(topicIdToLoad)) { setLoadError('Invalid topic ID format. Use format: 0.0.xxxxxx'); return; } setIsLoadingTemplate(true); setLoadError(null); try { if (!forceRefresh && hcsTemplate && hcsTemplate.topicId === topicIdToLoad && Date.now() - hcsTemplate.lastFetched.getTime() < 5 * 60 * 1000) { setIsLoadingTemplate(false); return; } const logger = new Logger({ module: 'TemplateLoader' }); const blockLoader = new BlockLoader(networkType, logger); const blockData = await blockLoader.loadBlock(topicIdToLoad); if (blockData && blockData.template) { const loadedTemplate: HCSTemplate = { template: blockData.template, definition: blockData.definition as unknown as Record, topicId: topicIdToLoad, lastFetched: new Date(), cached: true }; setHCSTemplate(loadedTemplate); onChange(blockData.template); if (blockData.definition && (blockData.definition as unknown as Record).attributes) { const { updateAttributes } = useBlockTesterStore.getState(); const structuredAttributes: Record> = {}; const attributes = (blockData.definition as unknown as Record).attributes as Record>; Object.entries(attributes).forEach(([key, attr]) => { structuredAttributes[key] = { schema: { type: attr.type || 'string', label: key.charAt(0).toUpperCase() + key.slice(1), required: attr.required || false, placeholder: attr.placeholder || `Enter ${key}`, default: attr.default }, value: attr.default || '' }; }); updateAttributes(structuredAttributes); } setTemplateSource('hcs'); } else { setLoadError('No template found for this topic ID'); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Failed to load template from HCS'; setLoadError(errorMessage); } finally { setIsLoadingTemplate(false); } }, [topicId, networkType, hcsTemplate, onChange]); const handleLoadTemplate = () => { if (topicId && validateTopicId(topicId)) { loadHCSTemplate(topicId, false); } }; const handleForceRefresh = () => { if (topicId && validateTopicId(topicId)) { loadHCSTemplate(topicId, true); } }; const handleEditorDidMount = (editor: monaco.editor.IStandaloneCodeEditor, monacoInstance: typeof monaco) => { monacoInstance.languages.register({ id: 'handlebars' }); monacoInstance.languages.setLanguageConfiguration('handlebars', { wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g, comments: { blockComment: ['{{!--', '--}}'], }, brackets: [ ['{{', '}}'], ['<', '>'], ['{', '}'], ['[', ']'], ['(', ')'], ], autoClosingPairs: [ { open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' }, { open: '"', close: '"' }, { open: "'", close: "'" }, { open: '{{', close: '}}' }, { open: '<', close: '>' }, ], surroundingPairs: [ { open: '"', close: '"' }, { open: "'", close: "'" }, { open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' }, { open: '<', close: '>' }, ], }); monacoInstance.languages.setMonarchTokensProvider('handlebars', { defaultToken: '', tokenPostfix: '.handlebars', tokenizer: { root: [ [/\{\{!--/, 'comment.block.handlebars', '@handlebarsComment'], [/\{\{[#\/]/, 'keyword.block.handlebars'], [/\{\{/, 'delimiter.handlebars'], [/\}\}/, 'delimiter.handlebars'], { include: 'html' } ], handlebarsComment: [ [/--\}\}/, 'comment.block.handlebars', '@pop'], [/./, 'comment.block.handlebars'] ], html: [ [/]+/, 'metatag.content'], [/>/, 'metatag', '@pop'], ], comment: [ [/-->/, 'comment', '@pop'], [/[^-]+/, 'comment.content'], [/./, 'comment.content'] ], otherTag: [ [/\/?>/, 'delimiter', '@pop'], [/"([^"]*)"/, 'attribute.value'], [/'([^']*)'/, 'attribute.value'], [/[\w\-]+/, 'attribute.name'], [/=/, 'delimiter'], [/[ \t\r\n]+/, ''], ], script: [ [/type/, 'attribute.name', '@scriptAfterType'], [/"([^"]*)"/, 'attribute.value'], [/'([^']*)'/, 'attribute.value'], [/[\w\-]+/, 'attribute.name'], [/=/, 'delimiter'], [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], [/[ \t\r\n]+/, ''], [/(<\/)(script\s*)(>)/, ['delimiter', 'tag', { token: 'delimiter', next: '@pop' }]] ], scriptAfterType: [ [/=/, 'delimiter', '@scriptAfterTypeEquals'], [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], [/[ \t\r\n]+/, ''], [/<\/script\s*>/, { token: '@rematch', next: '@pop' }] ], scriptAfterTypeEquals: [ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }], [/>/, { token: 'delimiter', next: '@scriptEmbedded', nextEmbedded: 'text/javascript' }], [/[ \t\r\n]+/, ''], [/<\/script\s*>/, { token: '@rematch', next: '@pop' }] ], scriptWithCustomType: [ [/>/, { token: 'delimiter', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }], [/"([^"]*)"/, 'attribute.value'], [/'([^']*)'/, 'attribute.value'], [/[\w\-]+/, 'attribute.name'], [/=/, 'delimiter'], [/[ \t\r\n]+/, ''], [/<\/script\s*>/, { token: '@rematch', next: '@pop' }] ], scriptEmbedded: [ [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], [/[^<]+/, ''] ], style: [ [/type/, 'attribute.name', '@styleAfterType'], [/"([^"]*)"/, 'attribute.value'], [/'([^']*)'/, 'attribute.value'], [/[\w\-]+/, 'attribute.name'], [/=/, 'delimiter'], [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], [/[ \t\r\n]+/, ''], [/(<\/)(style\s*)(>)/, ['delimiter', 'tag', { token: 'delimiter', next: '@pop' }]] ], styleAfterType: [ [/=/, 'delimiter', '@styleAfterTypeEquals'], [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], [/[ \t\r\n]+/, ''], [/<\/style\s*>/, { token: '@rematch', next: '@pop' }] ], styleAfterTypeEquals: [ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }], [/>/, { token: 'delimiter', next: '@styleEmbedded', nextEmbedded: 'text/css' }], [/[ \t\r\n]+/, ''], [/<\/style\s*>/, { token: '@rematch', next: '@pop' }] ], styleWithCustomType: [ [/>/, { token: 'delimiter', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }], [/"([^"]*)"/, 'attribute.value'], [/'([^']*)'/, 'attribute.value'], [/[\w\-]+/, 'attribute.name'], [/=/, 'delimiter'], [/[ \t\r\n]+/, ''], [/<\/style\s*>/, { token: '@rematch', next: '@pop' }] ], styleEmbedded: [ [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }], [/[^<]+/, ''] ], } }); monacoInstance.languages.registerCompletionItemProvider('handlebars', { provideCompletionItems: (model: monaco.editor.ITextModel, position: monaco.Position) => { const { attributes } = useBlockTesterStore.getState(); const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn }; const suggestions: monaco.languages.CompletionItem[] = [ { label: '{{content}}', kind: monacoInstance.languages.CompletionItemKind.Variable, insertText: '{{content}}', documentation: 'Main content variable', detail: 'Default content placeholder', range: range }, { label: '{{title}}', kind: monacoInstance.languages.CompletionItemKind.Variable, insertText: '{{title}}', documentation: 'Title attribute', detail: 'Block title variable', range: range }, { label: '{{description}}', kind: monacoInstance.languages.CompletionItemKind.Variable, insertText: '{{description}}', documentation: 'Description attribute', detail: 'Block description variable', range: range }, ...Object.keys(attributes).map(key => ({ label: `{{${key}}}`, kind: monacoInstance.languages.CompletionItemKind.Variable, insertText: `{{${key}}}`, documentation: `Attribute: ${key}`, detail: 'Block attribute variable', range: range })), { label: '{{#if condition}}', kind: monacoInstance.languages.CompletionItemKind.Snippet, insertText: '{{#if ${1:condition}}}\n $0\n{{/if}}', insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: 'Conditional block', detail: 'Handlebars if helper', range: range }, { label: '{{#each items}}', kind: monacoInstance.languages.CompletionItemKind.Snippet, insertText: '{{#each ${1:items}}}\n $0\n{{/each}}', insertTextRules: monacoInstance.languages.CompletionItemInsertTextRule.InsertAsSnippet, documentation: 'Loop block', detail: 'Handlebars each helper', range: range } ]; return { suggestions }; } }); const validateTemplate = (templateContent: string) => { try { Handlebars.compile(templateContent); const model = editor.getModel(); if (model) { monacoInstance.editor.setModelMarkers(model, 'handlebars', []); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Template syntax error'; const markers: monaco.editor.IMarkerData[] = [{ severity: monacoInstance.MarkerSeverity.Error, message: `Handlebars syntax error: ${errorMessage}`, startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }]; const model = editor.getModel(); if (model) { monacoInstance.editor.setModelMarkers(model, 'handlebars', markers); } } }; editor.onDidChangeModelContent(() => { const currentValue = editor.getValue(); if (currentValue) { validateTemplate(currentValue); } }); if (template) { validateTemplate(template); } monacoInstance.editor.defineTheme('hgo-dark', { base: 'vs-dark', inherit: true, rules: [ { token: 'tag', foreground: '#ff79c6', fontStyle: 'bold' }, { token: 'delimiter', foreground: '#f8f8f2' }, { token: 'delimiter.html', foreground: '#f8f8f2' }, { token: 'attribute.name', foreground: '#50fa7b' }, { token: 'attribute.value', foreground: '#f1fa8c' }, { token: 'delimiter.handlebars', foreground: '#ff79c6', fontStyle: 'bold' }, { token: 'keyword.block.handlebars', foreground: '#bd93f9', fontStyle: 'bold' }, { token: 'comment', foreground: '#6272a4', fontStyle: 'italic' }, { token: 'comment.block.handlebars', foreground: '#6272a4', fontStyle: 'italic' }, { token: 'metatag', foreground: '#ff79c6', fontStyle: 'bold' }, { token: 'metatag.content', foreground: '#f1fa8c' }, ], colors: { 'editor.background': '#0a0a0a', 'editor.foreground': '#f8f8f2', 'editorLineNumber.foreground': '#6b7280', 'editorCursor.foreground': '#f8f8f2', 'editor.selectionBackground': '#44475a', 'editor.lineHighlightBackground': '#1f293720', 'editorSuggestWidget.background': '#1f2937', 'editorSuggestWidget.border': '#374151', 'editorSuggestWidget.foreground': '#f9fafb', 'editorSuggestWidget.selectedBackground': '#44475a', 'editorIndentGuide.background': '#44475a', 'editorIndentGuide.activeBackground': '#6b7280', } }); monacoInstance.editor.defineTheme('hgo-light', { base: 'vs', inherit: true, rules: [ { token: 'tag', foreground: '#ec4899', fontStyle: 'bold' }, { token: 'delimiter', foreground: '#1f2937' }, { token: 'delimiter.html', foreground: '#1f2937' }, { token: 'attribute.name', foreground: '#10b981' }, { token: 'attribute.value', foreground: '#eab308' }, { token: 'delimiter.handlebars', foreground: '#ec4899', fontStyle: 'bold' }, { token: 'keyword.block.handlebars', foreground: '#a855f7', fontStyle: 'bold' }, { token: 'comment', foreground: '#9ca3af', fontStyle: 'italic' }, { token: 'comment.block.handlebars', foreground: '#9ca3af', fontStyle: 'italic' }, { token: 'metatag', foreground: '#ec4899', fontStyle: 'bold' }, { token: 'metatag.content', foreground: '#eab308' }, ], colors: { 'editor.background': '#ffffff', 'editor.foreground': '#111827', 'editorLineNumber.foreground': '#9ca3af', 'editorCursor.foreground': '#111827', 'editor.selectionBackground': '#ddd6fe50', 'editor.lineHighlightBackground': '#f9fafb50', 'editorSuggestWidget.background': '#ffffff', 'editorSuggestWidget.border': '#e5e7eb', 'editorSuggestWidget.foreground': '#111827', 'editorSuggestWidget.selectedBackground': '#f3f4f6', 'editorIndentGuide.background': '#e5e7eb', 'editorIndentGuide.activeBackground': '#9ca3af', } }); setIsEditorReady(true); }; const { updateAttributes } = useBlockTesterStore(); useEffect(() => { if (templateSourceProp) { if (templateSourceProp.type === 'hcs' && templateSourceProp.topicId) { setTemplateSource('hcs'); setTopicId(templateSourceProp.topicId); } else if (templateSourceProp.type === 'inline') { setTemplateSource('inline'); } } }, [templateSourceProp]); useEffect(() => { if (templateSource === 'hcs' && topicId && !hcsTemplate) { loadHCSTemplate(topicId, false); } }, [templateSource, topicId, hcsTemplate, loadHCSTemplate]); const handleExampleSelect = (example: TemplateExample) => { onChange(example.template); if (example.attributes) { const structuredAttributes: Record> = {}; Object.entries(example.attributes).forEach(([key, value]) => { structuredAttributes[key] = { schema: { type: 'string', label: key.charAt(0).toUpperCase() + key.slice(1), required: false, placeholder: `Enter ${key}` }, value: value }; }); updateAttributes(structuredAttributes); } }; const LoadingEditor = () => (
Loading Monaco Editor...
); const ErrorEditor = () => (
Editor Failed to Load Monaco Editor could not be loaded. Using fallback text editor.