2025-08-09 11:51:31 +03:30
|
|
|
<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 11:51:31 +03:30
|
|
|
|
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') {
|
2025-08-09 11:51:31 +03:30
|
|
|
self.MonacoEnvironment = {
|
2025-08-09 19:54:45 +03:30
|
|
|
getWorker: function (moduleId, label) {
|
2025-08-09 11:51:31 +03:30
|
|
|
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()
|
2025-08-09 11:51:31 +03:30
|
|
|
} 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
|
2025-08-09 11:51:31 +03:30
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
2025-08-09 11:51:31 +03:30
|
|
|
// تنظیمات پیشفرض
|
|
|
|
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()
|
2025-08-09 11:51:31 +03:30
|
|
|
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>
|