hesabixCore/webUI/src/components/MonacoEditor.vue

431 lines
13 KiB
Vue
Raw Normal View History

<template>
<div ref="editorContainer" class="monaco-editor-container" :style="{ height: height }"></div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'
2025-08-09 19:54:45 +03:30
// Vite-friendly worker imports
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
2025-08-09 19:54:45 +03:30
// Register Twig language and completion once per app lifetime
let twigEnhancementsRegistered = false
function registerTwigLanguageAndCompletions() {
if (twigEnhancementsRegistered) return
try {
// Register Twig language
monaco.languages.register({ id: 'twig', extensions: ['.twig'], aliases: ['Twig', 'twig'] })
// Basic Twig tokenizer
monaco.languages.setMonarchTokensProvider('twig', {
tokenPostfix: '.twig',
defaultToken: '',
brackets: [
{ open: '{{', close: '}}', token: 'delimiter.curly' },
{ open: '{%', close: '%}', token: 'delimiter.curly' },
{ open: '{#', close: '#}', token: 'comment' }
],
keywords: [
'if', 'elseif', 'else', 'endif',
'for', 'endfor', 'in',
'set', 'include', 'extends', 'with', 'only',
'block', 'endblock', 'macro', 'import', 'from', 'filter', 'endfilter'
],
filters: [
'upper', 'lower', 'capitalize', 'title', 'trim', 'replace', 'default', 'length', 'escape', 'raw', 'number_format'
],
tokenizer: {
root: [
[/\{#/, 'comment', '@comment'],
[/\{\{/, 'delimiter.twig', '@variable'],
[/\{%/, 'delimiter.twig', '@block'],
[/[^\{]+/, ''],
[/./, '']
],
comment: [
[/[^#}]+/, 'comment.content'],
[/#\}/, 'comment', '@pop']
],
variable: [
[/\}\}/, 'delimiter.twig', '@pop'],
[/\|\s*[a-zA-Z_][\w]*/, 'keyword'],
[/\b([a-zA-Z_][\w]*)\b/, 'variable'],
[/[^}]+/, '']
],
block: [
[/%\}/, 'delimiter.twig', '@pop'],
[/\b(if|elseif|else|endif|for|endfor|in|set|include|extends|with|only|block|endblock|macro|import|from|filter|endfilter)\b/, 'keyword'],
[/\b([a-zA-Z_][\w]*)\b/, 'identifier'],
[/[^%]+/, '']
]
}
})
// Twig variables for invoice templates (autocomplete)
const twigVariables = [
{ label: 'company_name', detail: 'نام شرکت' },
{ label: 'invoice_number', detail: 'شماره فاکتور' },
{ label: 'invoice_date', detail: 'تاریخ فاکتور' },
{ label: 'customer_name', detail: 'نام مشتری' },
{ label: 'total_amount', detail: 'مبلغ کل' },
{ label: 'items_list', detail: 'لیست اقلام' }
]
const twigKeywords = [
{ label: 'if', detail: 'ساختار شرطی' },
{ label: 'elseif', detail: 'شرط جایگزین' },
{ label: 'else', detail: 'بخش جایگزین' },
{ label: 'endif', detail: 'پایان if' },
{ label: 'for', detail: 'حلقه' },
{ label: 'endfor', detail: 'پایان for' },
{ label: 'in', detail: 'اپراتور in' },
{ label: 'set', detail: 'تعریف متغیر' },
{ label: 'include', detail: 'درج قالب' },
{ label: 'extends', detail: 'ارث‌بری قالب' },
{ label: 'block', detail: 'بلوک قالب' },
{ label: 'endblock', detail: 'پایان بلوک' },
{ label: 'filter', detail: 'اعمال فیلتر' },
{ label: 'endfilter', detail: 'پایان فیلتر' }
]
const twigSnippets = [
{
label: 'if ... endif',
detail: 'الگوی if',
insertText: '{% if ${1:condition} %}\n ${2:...}\n{% endif %}',
},
{
label: 'for ... endfor',
detail: 'الگوی for',
insertText: '{% for ${1:item} in ${2:items} %}\n ${3:...}\n{% endfor %}'
}
]
const mapToMonacoItems = (items, kind) => items.map((it) => ({
label: it.label,
kind,
insertText: it.insertText || it.label,
insertTextRules: it.insertText ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined,
detail: it.detail
}))
// Utility: determine if cursor is inside a Twig pair on the current line
function isInsideTwigContext(model, position) {
const lineText = model.getLineContent(position.lineNumber)
const textUntilPos = lineText.substring(0, position.column - 1)
const openVar = textUntilPos.lastIndexOf('{{')
const closeVar = textUntilPos.lastIndexOf('}}')
const openBlock = textUntilPos.lastIndexOf('{%')
const closeBlock = textUntilPos.lastIndexOf('%}')
const inVar = openVar > -1 && openVar > closeVar
const inBlock = openBlock > -1 && openBlock > closeBlock
return inVar || inBlock
}
// Completion provider for Twig language
monaco.languages.registerCompletionItemProvider('twig', {
triggerCharacters: ['{', '%', ' ', '.', '_', '|'],
provideCompletionItems(model, position) {
const suggestions = [
...mapToMonacoItems(twigVariables, monaco.languages.CompletionItemKind.Variable),
...mapToMonacoItems(twigKeywords, monaco.languages.CompletionItemKind.Keyword),
...mapToMonacoItems(twigSnippets, monaco.languages.CompletionItemKind.Snippet)
]
return { suggestions }
}
})
// Also provide Twig suggestions while editing HTML (mixed content)
monaco.languages.registerCompletionItemProvider('html', {
triggerCharacters: ['{', '%', ' ', '.', '_', '|'],
provideCompletionItems(model, position) {
if (!isInsideTwigContext(model, position)) return { suggestions: [] }
const suggestions = [
...mapToMonacoItems(twigVariables, monaco.languages.CompletionItemKind.Variable),
...mapToMonacoItems(twigKeywords, monaco.languages.CompletionItemKind.Keyword),
...mapToMonacoItems(twigSnippets, monaco.languages.CompletionItemKind.Snippet)
]
return { suggestions }
}
})
twigEnhancementsRegistered = true
} catch (err) {
console.warn('Failed to register Twig language/completions:', err)
}
}
// تنظیمات MonacoEnvironment برای web workers (سازگار با Vite)
if (typeof self !== 'undefined') {
self.MonacoEnvironment = {
2025-08-09 19:54:45 +03:30
getWorker: function (moduleId, label) {
try {
2025-08-09 19:54:45 +03:30
if (label === 'json') return new JsonWorker()
if (label === 'css' || label === 'scss' || label === 'less') return new CssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor') return new HtmlWorker()
if (label === 'typescript' || label === 'javascript') return new TsWorker()
return new EditorWorker()
} catch (error) {
2025-08-09 19:54:45 +03:30
console.warn('Monaco Editor worker failed to start, falling back to main thread:', error)
return undefined
}
}
}
}
export default {
name: 'MonacoEditor',
props: {
modelValue: {
type: String,
default: ''
},
language: {
type: String,
default: 'html'
},
theme: {
type: String,
default: 'vs-dark'
},
height: {
type: String,
default: '400px'
},
readOnly: {
type: Boolean,
default: false
},
options: {
type: Object,
default: () => ({})
}
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
const editorContainer = ref(null)
let editor = null
const createEditor = () => {
if (!editorContainer.value) return
try {
2025-08-09 19:54:45 +03:30
// Ensure Twig language and completions are available
registerTwigLanguageAndCompletions()
// تنظیمات پیش‌فرض
const defaultOptions = {
value: props.modelValue,
language: props.language,
theme: props.theme,
readOnly: props.readOnly,
automaticLayout: true,
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
lineNumbers: 'on',
roundedSelection: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
},
folding: true,
wordWrap: 'on',
suggestOnTriggerCharacters: true,
acceptSuggestionOnEnter: 'on',
tabCompletion: 'on',
wordBasedSuggestions: 'on',
parameterHints: {
enabled: true
},
autoIndent: 'full',
formatOnPaste: true,
formatOnType: false,
quickSuggestions: true,
hover: {
enabled: true
}
}
// ترکیب تنظیمات پیش‌فرض با تنظیمات سفارشی
const editorOptions = { ...defaultOptions, ...props.options }
editor = monaco.editor.create(editorContainer.value, editorOptions)
// اضافه کردن event listener برای تغییرات
editor.onDidChangeModelContent(() => {
const value = editor.getValue()
emit('update:modelValue', value)
emit('change', value)
})
// تنظیم زبان فارسی برای RTL
if (props.language === 'html') {
monaco.editor.setModelLanguage(editor.getModel(), 'html')
}
} catch (error) {
console.error('Error creating Monaco Editor:', error)
// Fallback to a simple textarea if Monaco fails
const textarea = document.createElement('textarea')
textarea.value = props.modelValue
textarea.style.width = '100%'
textarea.style.height = '100%'
textarea.style.fontFamily = 'Consolas, "Courier New", monospace'
textarea.style.fontSize = '14px'
textarea.style.border = 'none'
textarea.style.outline = 'none'
textarea.style.resize = 'none'
textarea.addEventListener('input', (e) => {
emit('update:modelValue', e.target.value)
emit('change', e.target.value)
})
editorContainer.value.appendChild(textarea)
}
}
const destroyEditor = () => {
if (editor) {
try {
editor.dispose()
} catch (error) {
console.warn('Error disposing Monaco Editor:', error)
}
editor = null
}
// Clean up fallback textarea if it exists
if (editorContainer.value) {
const textarea = editorContainer.value.querySelector('textarea')
if (textarea) {
textarea.remove()
}
}
}
// تماشای تغییرات props
watch(() => props.modelValue, (newValue) => {
if (editor && newValue !== editor.getValue()) {
editor.setValue(newValue)
}
})
watch(() => props.language, (newLanguage) => {
2025-08-09 19:54:45 +03:30
// Ensure Twig language is registered if needed when language changes
registerTwigLanguageAndCompletions()
if (editor) {
monaco.editor.setModelLanguage(editor.getModel(), newLanguage)
}
})
watch(() => props.theme, (newTheme) => {
if (editor) {
monaco.editor.setTheme(newTheme)
}
})
watch(() => props.readOnly, (newReadOnly) => {
if (editor) {
editor.updateOptions({ readOnly: newReadOnly })
}
})
onMounted(() => {
createEditor()
})
onBeforeUnmount(() => {
destroyEditor()
})
// متدهای عمومی برای دسترسی از خارج
const getValue = () => {
if (editor) {
return editor.getValue()
}
// Fallback for textarea
if (editorContainer.value) {
const textarea = editorContainer.value.querySelector('textarea')
return textarea ? textarea.value : ''
}
return ''
}
const setValue = (value) => {
if (editor) {
editor.setValue(value)
} else if (editorContainer.value) {
// Fallback for textarea
const textarea = editorContainer.value.querySelector('textarea')
if (textarea) {
textarea.value = value
}
}
}
const focus = () => {
if (editor) {
editor.focus()
}
}
const getEditor = () => {
return editor
}
return {
editorContainer,
getValue,
setValue,
focus,
getEditor
}
}
}
</script>
<style scoped>
.monaco-editor-container {
width: 100%;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
/* تنظیمات برای RTL */
.monaco-editor-container :deep(.monaco-editor) {
direction: ltr;
}
.monaco-editor-container :deep(.monaco-editor .margin) {
direction: ltr;
}
.monaco-editor-container :deep(.monaco-editor .monaco-editor-background) {
direction: ltr;
}
/* تنظیمات برای تم تاریک */
.monaco-editor-container :deep(.vs-dark) {
background-color: #1e1e1e;
}
/* تنظیمات برای تم روشن */
.monaco-editor-container :deep(.vs) {
background-color: #ffffff;
}
/* تنظیمات برای تم HC */
.monaco-editor-container :deep(.hc-black) {
background-color: #000000;
}
</style>