(function () { 'use strict'; class TinyMikuSyntaxHighlighter { constructor() { } escapeHtml(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } wrapSpan(text, className) { return `${text}`; } highlight(code) { if (!code) return ''; let result = this.escapeHtml(code); let i = 0; let highlightedResult = ''; while (i < result.length) { if (result.substr(i, 3) === '```') { const blockStart = i; let lineEnd = result.indexOf('\n', i); if (lineEnd === -1) lineEnd = result.length; const firstLine = result.substring(i, lineEnd); const langMatch = firstLine.match(/```(lang-\w*)/); const blockEnd = result.indexOf('\n```', lineEnd); if (blockEnd !== -1) { const content = result.substring(lineEnd + 1, blockEnd); highlightedResult += this.wrapSpan('```', 'miku-syntax-html-tag'); if (langMatch) { const langAttr = langMatch[1]; highlightedResult += this.wrapSpan(langAttr, 'miku-syntax-html-attribute'); } highlightedResult += '\n'; if (content) { highlightedResult += this.wrapSpan(content, 'miku-syntax-html-string'); } highlightedResult += '\n' + this.wrapSpan('```', 'miku-syntax-html-tag'); i = blockEnd + 4; continue; } } if (result.substr(i, 2) === '$$') { const mathStart = i; const mathEnd = result.indexOf('$$', i + 2); if (mathEnd !== -1) { const mathContent = result.substring(mathStart, mathEnd + 2); highlightedResult += this.wrapSpan(mathContent, 'miku-syntax-css-value'); i = mathEnd + 2; continue; } } if (result[i] === '$' && (i === 0 || result[i - 1] !== '$') && (i + 1 >= result.length || result[i + 1] !== '$')) { const mathStart = i; const mathEnd = result.indexOf('$', i + 1); if (mathEnd !== -1) { const mathContent = result.substring(mathStart, mathEnd + 1); highlightedResult += this.wrapSpan(mathContent, 'miku-syntax-css-property'); i = mathEnd + 1; continue; } } if (result.substr(i, 2) === '**') { const boldStart = i; const boldEnd = result.indexOf('**', i + 2); if (boldEnd !== -1) { const boldContent = result.substring(boldStart, boldEnd + 2); highlightedResult += this.wrapSpan(boldContent, 'miku-syntax-js-keyword'); i = boldEnd + 2; continue; } } if (result.substr(i, 2) === '__') { const italicStart = i; const italicEnd = result.indexOf('__', i + 2); if (italicEnd !== -1) { const italicContent = result.substring(italicStart, italicEnd + 2); highlightedResult += this.wrapSpan(italicContent, 'miku-syntax-js-function'); i = italicEnd + 2; continue; } } if (result.substr(i, 2) === '~~') { const strikeStart = i; const strikeEnd = result.indexOf('~~', i + 2); if (strikeEnd !== -1) { const strikeContent = result.substring(strikeStart, strikeEnd + 2); highlightedResult += this.wrapSpan(strikeContent, 'miku-syntax-html-comment'); i = strikeEnd + 2; continue; } } highlightedResult += result[i]; i++; } return highlightedResult; } } class TinyMikuAutocomplete { constructor() { this.markupTags = [ { text: '**', type: 'bold', insertText: '****' }, { text: '__', type: 'italic', insertText: '____' }, { text: '~~', type: 'strikethrough', insertText: '~~~~' }, { text: '```', type: 'code-block', insertText: '```lang-\n\n```' }, { text: '$$', type: 'math-block', insertText: '$$$$' }, { text: '$', type: 'math-inline', insertText: '$$' } ]; } getSuggestions(prefix, context, customWords = []) { const suggestions = []; const lowerPrefix = prefix.toLowerCase(); this.markupTags.forEach(tag => { if (tag.text.startsWith(prefix)) { suggestions.push(tag); } }); if (customWords && customWords.size > 0) { customWords.forEach(word => { if (word.toLowerCase().startsWith(lowerPrefix) && word.toLowerCase() !== lowerPrefix && !suggestions.some(s => s.text === word)) { suggestions.push({ text: word, type: 'custom-word', insertText: word }); } }); } return suggestions.slice(0, 10); } } class TinyMiku { constructor(selector, options = {}) { if (typeof selector === 'string') { this.container = document.querySelector(selector); } else { this.container = selector; } if (!this.container) { throw new Error('Tiny Miku Editor: Element not found'); } this.options = { placeholder: 'Start typing...', ...options }; this.highlighter = new TinyMikuSyntaxHighlighter(); this.autocomplete = new TinyMikuAutocomplete(); this.selectedIndex = -1; this.suggestions = []; this.currentPrefix = ''; this.customWords = new Set(); this.init(); } init() { this.createEditor(); this.bindEvents(); this.updateSyntaxHighlighting(); } createEditor() { const existingContent = this.container.textContent || this.container.innerText || ''; this.container.className = 'miku-editor-container'; this.container.innerHTML = `
`; this.editor = this.container.querySelector('#mikuEditor'); this.syntaxHighlight = this.container.querySelector('#mikuSyntaxHighlight'); this.dropdown = this.container.querySelector('#mikuAutocompleteDropdown'); } bindEvents() { this.editor.addEventListener('input', () => { this.updateSyntaxHighlighting(); this.handleInput(); }); this.editor.addEventListener('keydown', (e) => { this.handleKeyDown(e); }); this.editor.addEventListener('scroll', () => { this.syntaxHighlight.scrollTop = this.editor.scrollTop; this.syntaxHighlight.scrollLeft = this.editor.scrollLeft; if (this.dropdown.style.display === 'block') { const cursorPos = this.editor.selectionStart; if (cursorPos !== undefined) { this.positionDropdown(cursorPos); } } }); this.editor.addEventListener('click', () => { if (this.dropdown.style.display === 'block') { const cursorPos = this.editor.selectionStart; if (cursorPos !== undefined) { this.positionDropdown(cursorPos); } } }); this.editor.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { if (this.dropdown.style.display === 'block') { const cursorPos = this.editor.selectionStart; if (cursorPos !== undefined) { this.positionDropdown(cursorPos); } } } }); document.addEventListener('click', (e) => { if (!this.dropdown.contains(e.target) && e.target !== this.editor) { this.hideDropdown(); } }); } updateSyntaxHighlighting() { const code = this.editor.value; const highlighted = this.highlighter.highlight(code); this.syntaxHighlight.innerHTML = highlighted; this.syntaxHighlight.scrollTop = this.editor.scrollTop; this.syntaxHighlight.scrollLeft = this.editor.scrollLeft; } handleInput() { const cursorPos = this.editor.selectionStart; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const currentWord = this.getCurrentWord(textBeforeCursor); this.extractCustomWords(); if (currentWord.length > 0) { this.showAutocomplete(currentWord, cursorPos); } else { this.hideDropdown(); } } extractCustomWords() { const content = this.editor.value; const wordPattern = /[\w-]{3,}/g; const words = content.match(wordPattern) || []; words.forEach(word => { if (word.length >= 3) { this.customWords.add(word); } }); } getCurrentWord(text) { const match = text.match(/[\w\-\*\_\~\`\$]*$/); return match ? match[0] : ''; } showAutocomplete(prefix, cursorPos) { this.currentPrefix = prefix; this.suggestions = this.autocomplete.getSuggestions(prefix, null, this.customWords); if (this.suggestions.length === 0) { this.hideDropdown(); return; } this.renderDropdown(); this.positionDropdown(cursorPos); this.selectedIndex = 0; this.updateSelection(); } renderDropdown() { this.dropdown.innerHTML = ''; this.suggestions.forEach((suggestion, index) => { const item = document.createElement('div'); item.className = 'miku-editor-autocomplete-item'; if (suggestion.type === 'custom-word') { item.classList.add('custom-word-suggestion'); } item.innerHTML = ` ${suggestion.text} ${suggestion.type} `; item.addEventListener('click', () => { this.insertSuggestion(suggestion); }); this.dropdown.appendChild(item); }); this.dropdown.style.display = 'block'; } positionDropdown(cursorPos) { const coords = this.getCursorCoordinates(cursorPos); const editorRect = this.editor.getBoundingClientRect(); const viewportHeight = window.innerHeight; let leftPos = coords.x; const dropdownWidth = 200; const dropdownHeight = Math.min(200, this.suggestions.length * 34); if (leftPos + dropdownWidth > editorRect.right - 5) { leftPos = Math.max(editorRect.right - dropdownWidth - 5, editorRect.left + 5); } if (leftPos < editorRect.left + 5) { leftPos = editorRect.left + 5; } let topPos = coords.y + 25; if (topPos + dropdownHeight > Math.min(editorRect.bottom - 5, viewportHeight - 10)) { topPos = coords.y - dropdownHeight - 5; if (topPos < 10) { topPos = coords.y + 25; } } this.dropdown.style.left = leftPos + 'px'; this.dropdown.style.top = topPos + 'px'; } getCursorCoordinates(cursorPos) { const editorRect = this.editor.getBoundingClientRect(); const editorStyles = window.getComputedStyle(this.editor); const tempElement = document.createElement('div'); tempElement.style.position = 'absolute'; tempElement.style.visibility = 'hidden'; tempElement.style.whiteSpace = 'pre-wrap'; tempElement.style.wordWrap = 'break-word'; tempElement.style.font = editorStyles.font; tempElement.style.fontSize = editorStyles.fontSize; tempElement.style.fontFamily = editorStyles.fontFamily; tempElement.style.lineHeight = editorStyles.lineHeight; tempElement.style.padding = editorStyles.padding; tempElement.style.border = editorStyles.border; tempElement.style.width = this.editor.offsetWidth + 'px'; tempElement.style.height = 'auto'; tempElement.style.overflow = 'hidden'; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(cursorPos); const beforeSpan = document.createElement('span'); beforeSpan.textContent = textBeforeCursor; const cursorSpan = document.createElement('span'); cursorSpan.textContent = '|'; cursorSpan.style.color = 'transparent'; const afterSpan = document.createElement('span'); afterSpan.textContent = textAfterCursor; tempElement.appendChild(beforeSpan); tempElement.appendChild(cursorSpan); tempElement.appendChild(afterSpan); document.body.appendChild(tempElement); const cursorRect = cursorSpan.getBoundingClientRect(); const tempRect = tempElement.getBoundingClientRect(); document.body.removeChild(tempElement); const x = editorRect.left + (cursorRect.left - tempRect.left) - this.editor.scrollLeft; const y = editorRect.top + (cursorRect.top - tempRect.top) - this.editor.scrollTop; return { x: x, y: y }; } handleKeyDown(e) { if (this.dropdown.style.display === 'block') { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.selectedIndex = Math.min(this.selectedIndex + 1, this.suggestions.length - 1); this.updateSelection(); break; case 'ArrowUp': e.preventDefault(); this.selectedIndex = Math.max(this.selectedIndex - 1, 0); this.updateSelection(); break; case 'Tab': case 'Enter': e.preventDefault(); if (this.selectedIndex >= 0) { this.insertSuggestion(this.suggestions[this.selectedIndex]); } break; case 'Escape': this.hideDropdown(); break; } return; } if (e.key === 'Tab') { e.preventDefault(); this.insertTabSpaces(); return; } if (e.key === 'Enter') { this.handleAutoIndent(e); return; } this.handleAutoClose(e); } handleAutoClose(e) { const bracketPairs = { '(': ')', '[': ']', '{': '}', '<': '>' }; const quotePairs = { '"': '"', // "'": "'" }; const key = e.key; if (bracketPairs[key] || quotePairs[key]) { e.preventDefault(); const cursorPos = this.editor.selectionStart; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(cursorPos); const selectedText = this.editor.value.substring(this.editor.selectionStart, this.editor.selectionEnd); let closingChar; if (bracketPairs[key]) { closingChar = bracketPairs[key]; } else if (quotePairs[key]) { closingChar = quotePairs[key]; if (textAfterCursor.startsWith(closingChar)) { this.insertText(key); return; } } if (selectedText) { const newText = key + selectedText + closingChar; this.insertText(newText); this.editor.setSelectionRange(cursorPos + 1, cursorPos + 1 + selectedText.length); } else { this.insertText(key + closingChar); this.editor.setSelectionRange(cursorPos + 1, cursorPos + 1); } } } insertText(text) { const cursorPos = this.editor.selectionStart; const selectionEnd = this.editor.selectionEnd; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(selectionEnd); this.editor.value = textBeforeCursor + text + textAfterCursor; this.updateSyntaxHighlighting(); } updateSelection() { const items = this.dropdown.querySelectorAll('.miku-editor-autocomplete-item'); items.forEach((item, index) => { item.classList.toggle('selected', index === this.selectedIndex); }); if (items[this.selectedIndex]) { items[this.selectedIndex].scrollIntoView({ block: 'nearest' }); } } insertSuggestion(suggestion) { const cursorPos = this.editor.selectionStart; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(cursorPos); const prefixStart = cursorPos - this.currentPrefix.length; const newText = textBeforeCursor.substring(0, prefixStart) + suggestion.insertText + textAfterCursor; this.editor.value = newText; let newCursorPos = prefixStart + suggestion.insertText.length; if (suggestion.insertText === '****') { newCursorPos = prefixStart + 2; } else if (suggestion.insertText === '____') { newCursorPos = prefixStart + 2; } else if (suggestion.insertText === '~~~~') { newCursorPos = prefixStart + 2; } else if (suggestion.insertText === '$$') { newCursorPos = prefixStart + 2; } else if (suggestion.insertText === '$' && suggestion.type === 'math-inline') { newCursorPos = prefixStart + 1; } else if (suggestion.insertText === '```lang-\n\n```') { newCursorPos = prefixStart + 7; } this.editor.setSelectionRange(newCursorPos, newCursorPos); this.editor.focus(); this.hideDropdown(); this.updateSyntaxHighlighting(); } insertTabSpaces() { const tabSize = 4; const cursorPos = this.editor.selectionStart; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(cursorPos); const spaces = ' '.repeat(tabSize); const newText = textBeforeCursor + spaces + textAfterCursor; this.editor.value = newText; const newCursorPos = cursorPos + tabSize; this.editor.setSelectionRange(newCursorPos, newCursorPos); this.updateSyntaxHighlighting(); } handleAutoIndent(e) { if (e.key === 'Enter') { e.preventDefault(); const cursorPos = this.editor.selectionStart; const textBeforeCursor = this.editor.value.substring(0, cursorPos); const textAfterCursor = this.editor.value.substring(cursorPos); const currentLineMatch = textBeforeCursor.match(/[^\n]*$/); const currentLine = currentLineMatch ? currentLineMatch[0] : ''; const indentMatch = currentLine.match(/^[ \t]*/); const indent = indentMatch ? indentMatch[0] : ''; this.editor.value = textBeforeCursor + '\n' + indent + textAfterCursor; const newCursorPos = cursorPos + 1 + indent.length; this.editor.setSelectionRange(newCursorPos, newCursorPos); this.updateSyntaxHighlighting(); } } hideDropdown() { this.dropdown.style.display = 'none'; this.selectedIndex = -1; this.suggestions = []; } getContent() { return this.editor.value; } } window.TinyMiku = TinyMiku; })();