From 07d841c51e70d556e44506e1536509019e672557 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Sat, 23 Aug 2025 16:37:01 +0800 Subject: [PATCH 1/4] feat: new codeHighlighter API --- SYNTAX_HIGHLIGHTING.md | 408 +++++++++++++++++++++++++++++++++++++++++ src/overtype.js | 260 ++++++++++++++------------ src/parser.js | 214 ++++++++++++--------- 3 files changed, 681 insertions(+), 201 deletions(-) create mode 100644 SYNTAX_HIGHLIGHTING.md diff --git a/SYNTAX_HIGHLIGHTING.md b/SYNTAX_HIGHLIGHTING.md new file mode 100644 index 0000000..d242e1b --- /dev/null +++ b/SYNTAX_HIGHLIGHTING.md @@ -0,0 +1,408 @@ +# OverType Syntax Highlighting API + +OverType provides a simple yet powerful API for integrating custom syntax highlighting libraries with your markdown editor. This document explains how to use the highlighting API and provides examples for popular highlighting libraries. + +## Overview + +The OverType syntax highlighting API allows you to: + +- **Global Highlighting**: Set a highlighter that applies to all OverType instances +- **Per-Instance Highlighting**: Set a highlighter for specific editor instances +- **Library Agnostic**: Works with any highlighting library (Shiki, Prism, highlight.js, etc.) +- **Real-time**: Highlights code as you type +- **Preserves Alignment**: Maintains perfect character positioning for the WYSIWYG experience + +## Basic Usage + +### Global Code Highlighter + +```javascript +// Set a global highlighter that applies to all OverType instances +OverType.setCodeHighlighter((code, language) => { + // Your highlighting logic here + return highlightedHtml; +}); +``` + +### Per-Instance Code Highlighter + +```javascript +// Option 1: Set during initialization +const [editor] = new OverType('#editor', { + codeHighlighter: (code, language) => { + return highlightedHtml; + } +}); + +// Option 2: Set after initialization +editor.setCodeHighlighter((code, language) => { + return highlightedHtml; +}); +``` + +### Disable Highlighting + +```javascript +// Disable global highlighting +OverType.setCodeHighlighter(null); + +// Disable per-instance highlighting +editor.setCodeHighlighter(null); +``` + +## API Contract + +### Highlighter Function Signature + +```javascript +function highlighter(code, language) { + // Parameters: + // - code: string - The raw code content to highlight + // - language: string - Language extracted from fence (e.g., 'javascript', 'python', '') + + // Returns: + // - string - HTML with syntax highlighting +} +``` + +### Requirements + +1. **Preserve Character Positions**: The returned HTML must maintain the same character positions as the input +2. **Handle Unknown Languages**: Should gracefully handle languages not supported by your highlighter +3. **Escape HTML**: Must return properly escaped HTML if the highlighter doesn't handle escaping +4. **Performance**: Should be fast enough for real-time highlighting (consider debouncing for heavy highlighters) +5. **Error Handling**: Should not throw errors; fallback to plain text if highlighting fails + +## Examples + +### 1. Simple Pattern-Based Highlighter + +```javascript +function simpleHighlighter(code, language) { + return code + // Keywords + .replace(/\b(function|const|let|var|if|else|for|while|return|class)\b/g, + '$1') + // Strings + .replace(/(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, + '$1$2$1') + // Comments + .replace(/(\/\/.*$|#.*$)/gm, + '$1') + // Numbers + .replace(/\b(\d+(?:\.\d+)?)\b/g, + '$1'); +} + +OverType.setCodeHighlighter(simpleHighlighter); +``` + +### 2. Shiki.js Integration (v3.0+) + +```javascript +import { codeToHtml } from 'shiki'; + +// Async highlighter function +async function shikiHighlighter(code, language) { + try { + // Map common aliases + const langMap = { + 'js': 'javascript', + 'ts': 'typescript', + 'py': 'python', + 'rs': 'rust' + }; + + const normalizedLang = langMap[language] || language || 'text'; + + const highlighted = await codeToHtml(code, { + lang: normalizedLang, + theme: 'github-light' + }); + + // Extract inner HTML from pre>code element + const match = highlighted.match(/]*>([\s\S]*?)<\/code>/); + return match ? match[1] : code; + + } catch (error) { + console.warn('Shiki highlighting failed:', error); + return code; // Fallback to plain text + } +} + +// Synchronous wrapper with caching for real-time highlighting +const highlightCache = new Map(); + +function syncShikiHighlighter(code, language) { + const cacheKey = `${language}:${code.substring(0, 100)}`; + + if (highlightCache.has(cacheKey)) { + return highlightCache.get(cacheKey); + } + + // Start async highlighting + shikiHighlighter(code, language).then(result => { + highlightCache.set(cacheKey, result); + // Trigger re-render + OverType.setCodeHighlighter(syncShikiHighlighter); + }); + + return code; // Return plain code while highlighting +} + +OverType.setCodeHighlighter(syncShikiHighlighter); +``` + +### 2b. Shiki.js Legacy (v0.14) + +```javascript +import { getHighlighter } from 'shiki@0.14.7'; + +let shikiHighlighter = null; + +async function initShiki() { + shikiHighlighter = await getHighlighter({ + themes: ['github-light', 'github-dark'], + langs: ['javascript', 'typescript', 'python', 'rust', 'go'] + }); + + OverType.setCodeHighlighter((code, language) => { + if (!shikiHighlighter) return code; + + try { + const langMap = { + 'js': 'javascript', + 'ts': 'typescript', + 'py': 'python', + 'rs': 'rust' + }; + + const normalizedLang = langMap[language] || language || 'text'; + + if (!shikiHighlighter.getLoadedLanguages().includes(normalizedLang)) { + return code; + } + + const highlighted = shikiHighlighter.codeToHtml(code, { + lang: normalizedLang, + theme: 'github-light' + }); + + const match = highlighted.match(/]*>([\s\S]*?)<\/code>/); + return match ? match[1] : code; + + } catch (error) { + console.warn('Shiki highlighting failed:', error); + return code; + } + }); +} + +initShiki(); +``` + +### 3. Prism.js Integration + +```javascript +import Prism from 'prismjs'; +// Import languages you need +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-rust'; + +function prismHighlighter(code, language) { + try { + // Map aliases + const langMap = { + 'js': 'javascript', + 'py': 'python', + 'rs': 'rust' + }; + + const normalizedLang = langMap[language] || language; + + if (Prism.languages[normalizedLang]) { + return Prism.highlight(code, Prism.languages[normalizedLang], normalizedLang); + } + + return code; // Fallback for unsupported languages + } catch (error) { + console.warn('Prism highlighting failed:', error); + return code; + } +} + +OverType.setCodeHighlighter(prismHighlighter); +``` + +### 4. highlight.js Integration + +```javascript +import hljs from 'highlight.js'; + +function hljsHighlighter(code, language) { + try { + if (language && hljs.getLanguage(language)) { + const result = hljs.highlight(code, { language }); + return result.value; + } else { + // Auto-detect language + const result = hljs.highlightAuto(code); + return result.value; + } + } catch (error) { + console.warn('highlight.js highlighting failed:', error); + return hljs.util.escapeHtml(code); + } +} + +OverType.setCodeHighlighter(hljsHighlighter); +``` + +### 5. Language-Specific Highlighters + +```javascript +// Different highlighters for different languages +function multiHighlighter(code, language) { + switch (language) { + case 'json': + return highlightJson(code); + case 'sql': + return highlightSql(code); + case 'javascript': + case 'js': + return highlightJavaScript(code); + default: + return simpleHighlighter(code, language); + } +} + +function highlightJson(code) { + return code + .replace(/(["'])((?:\\.|(?!\1)[^\\])*?)(\1)(\s*:\s*)/g, + '$1$2$3$4') + .replace(/:\s*(["'])((?:\\.|(?!\1)[^\\])*?)\1/g, + ': $1$2$1') + .replace(/:\s*(\d+(?:\.\d+)?)/g, + ': $1') + .replace(/:\s*(true|false|null)/g, + ': $1'); +} + +OverType.setCodeHighlighter(multiHighlighter); +``` + +## Performance Considerations + +### Debouncing for Heavy Highlighters + +```javascript +let highlightTimeout; + +function debouncedHighlighter(code, language) { + return new Promise((resolve) => { + clearTimeout(highlightTimeout); + highlightTimeout = setTimeout(() => { + resolve(heavyHighlighter(code, language)); + }, 150); // 150ms debounce + }); +} + +// For async highlighters, you might need a synchronous wrapper +let highlightCache = new Map(); + +function cachedAsyncHighlighter(code, language) { + const cacheKey = `${language}:${code}`; + + if (highlightCache.has(cacheKey)) { + return highlightCache.get(cacheKey); + } + + // Start async highlighting + heavyAsyncHighlighter(code, language).then(result => { + highlightCache.set(cacheKey, result); + // Trigger re-render if needed + OverType.setCodeHighlighter(cachedAsyncHighlighter); + }); + + // Return plain text while highlighting is in progress + return code; +} +``` + +### Language Detection + +```javascript +function detectLanguage(code, suggestedLanguage) { + // Use suggested language if valid + if (suggestedLanguage && supportedLanguages.includes(suggestedLanguage)) { + return suggestedLanguage; + } + + // Simple heuristics for common languages + if (/^\s*{[\s\S]*}\s*$/.test(code.trim())) { + return 'json'; + } + if (/\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE)\b/i.test(code)) { + return 'sql'; + } + if (/\b(function|const|let|var|=>)\b/.test(code)) { + return 'javascript'; + } + if (/\b(def|import|from|class|if __name__)\b/.test(code)) { + return 'python'; + } + + return 'text'; +} + +function smartHighlighter(code, language) { + const detectedLanguage = detectLanguage(code, language); + return actualHighlighter(code, detectedLanguage); +} +``` + +## Best Practices + +1. **Always provide fallbacks**: If highlighting fails, return the original code +2. **Handle edge cases**: Empty strings, very large code blocks, unsupported languages +3. **Consider performance**: Use caching, debouncing, or web workers for heavy highlighting +4. **Test thoroughly**: Test with various languages, edge cases, and large documents +5. **Provide user feedback**: Show loading states or errors when appropriate + +## Troubleshooting + +### Common Issues + +1. **Characters not aligning**: Make sure your highlighter preserves all whitespace and character positions +2. **Performance problems**: Consider debouncing or caching for expensive highlighting operations +3. **Languages not working**: Check that your highlighter library supports the requested language +4. **HTML escaping issues**: Ensure proper HTML escaping to prevent XSS vulnerabilities + +### Debug Mode + +```javascript +function debugHighlighter(code, language) { + console.log('Highlighting:', { language, codeLength: code.length }); + + try { + const result = yourHighlighter(code, language); + console.log('Highlight success:', { resultLength: result.length }); + return result; + } catch (error) { + console.error('Highlight failed:', error); + return code; + } +} + +OverType.setCodeHighlighter(debugHighlighter); +``` + +## Integration Examples + +Complete integration examples are available in the `examples/` directory: + +- `examples/syntax-highlighting-api.html` - Basic API demonstration +- `examples/shiki-integration.html` - Full Shiki.js integration with themes and language support + +These examples show real-world usage patterns and can serve as starting points for your own implementations. diff --git a/src/overtype.js b/src/overtype.js index 02eb48c..8044d7a 100644 --- a/src/overtype.js +++ b/src/overtype.js @@ -30,7 +30,7 @@ class OverType { constructor(target, options = {}) { // Convert target to array of elements let elements; - + if (typeof target === 'string') { elements = document.querySelectorAll(target); if (elements.length === 0) { @@ -73,10 +73,10 @@ class OverType { */ _init(element, options = {}) { this.element = element; - + // Store the original theme option before merging this.instanceTheme = options.theme || null; - + this.options = this._mergeOptions(options); this.instanceId = ++OverType.instanceCount; this.initialized = false; @@ -98,7 +98,7 @@ class OverType { // Setup shortcuts manager this.shortcuts = new ShortcutsManager(this); - + // Setup link tooltip this.linkTooltip = new LinkTooltip(this); @@ -106,7 +106,7 @@ class OverType { if (this.options.toolbar) { this.toolbar = new Toolbar(this); this.toolbar.create(); - + // Update toolbar states on selection change this.textarea.addEventListener('selectionchange', () => { this.toolbar.updateButtonStates(); @@ -137,17 +137,17 @@ class OverType { /* System-first, guaranteed monospaced; avoids Android 'ui-monospace' pitfalls */ fontFamily: '"SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Code", Consolas, "Roboto Mono", "Noto Sans Mono", "Droid Sans Mono", "Ubuntu Mono", "DejaVu Sans Mono", "Liberation Mono", "Courier New", Courier, monospace', padding: '16px', - + // Mobile styles mobile: { fontSize: '16px', // Prevent zoom on iOS padding: '12px', lineHeight: 1.5 }, - + // Native textarea properties textareaProps: {}, - + // Behavior autofocus: false, autoResize: false, // Auto-expand height with content @@ -155,22 +155,23 @@ class OverType { maxHeight: null, // Maximum height for autoResize mode (null = unlimited) placeholder: 'Start typing...', value: '', - + // Callbacks onChange: null, onKeydown: null, - + // Features showActiveLineRaw: false, showStats: false, toolbar: false, statsFormatter: null, - smartLists: true // Enable smart list continuation + smartLists: true, // Enable smart list continuation + codeHighlighter: null // Per-instance code highlighter }; - + // Remove theme and colors from options - these are now global const { theme, colors, ...cleanOptions } = options; - + return { ...defaults, ...cleanOptions @@ -198,7 +199,7 @@ class OverType { if (themeName) { this.container.setAttribute('data-theme', themeName); } - + // If using instance theme, apply CSS variables to container if (this.instanceTheme) { const themeObj = typeof this.instanceTheme === 'string' ? getTheme(this.instanceTheme) : this.instanceTheme; @@ -210,7 +211,7 @@ class OverType { wrapper.parentNode.insertBefore(this.container, wrapper); this.container.appendChild(wrapper); } - + if (!this.wrapper) { // No valid structure found if (container) container.remove(); @@ -218,7 +219,7 @@ class OverType { this._buildFromScratch(); return; } - + this.textarea = this.wrapper.querySelector('.overtype-input'); this.preview = this.wrapper.querySelector('.overtype-preview'); @@ -231,7 +232,7 @@ class OverType { // Store reference on wrapper this.wrapper._instance = this; - + // Apply instance-specific styles via CSS custom properties if (this.options.fontSize) { this.wrapper.style.setProperty('--instance-font-size', this.options.fontSize); @@ -294,14 +295,14 @@ class OverType { // Create container that will hold toolbar and editor this.container = document.createElement('div'); this.container.className = 'overtype-container'; - + // Set theme on container - use instance theme if provided const themeToUse = this.instanceTheme || OverType.currentTheme || solar; const themeName = typeof themeToUse === 'string' ? themeToUse : themeToUse.name; if (themeName) { this.container.setAttribute('data-theme', themeName); } - + // If using instance theme, apply CSS variables to container if (this.instanceTheme) { const themeObj = typeof this.instanceTheme === 'string' ? getTheme(this.instanceTheme) : this.instanceTheme; @@ -310,12 +311,12 @@ class OverType { this.container.style.cssText += cssVars; } } - + // Create wrapper for editor this.wrapper = document.createElement('div'); this.wrapper.className = 'overtype-wrapper'; - - + + // Apply instance-specific styles via CSS custom properties if (this.options.fontSize) { this.wrapper.style.setProperty('--instance-font-size', this.options.fontSize); @@ -326,7 +327,7 @@ class OverType { if (this.options.padding) { this.wrapper.style.setProperty('--instance-padding', this.options.padding); } - + this.wrapper._instance = this; // Create textarea @@ -334,7 +335,7 @@ class OverType { this.textarea.className = 'overtype-input'; this.textarea.placeholder = this.options.placeholder; this._configureTextarea(); - + // Apply any native textarea properties if (this.options.textareaProps) { Object.entries(this.options.textareaProps).forEach(([key, value]) => { @@ -356,12 +357,12 @@ class OverType { // Assemble DOM this.wrapper.appendChild(this.textarea); this.wrapper.appendChild(this.preview); - + // No need to prevent link clicks - pointer-events handles this - + // Add wrapper to container first this.container.appendChild(this.wrapper); - + // Add stats bar at the end (bottom) if enabled if (this.options.showStats) { this.statsBar = document.createElement('div'); @@ -369,10 +370,10 @@ class OverType { this.container.appendChild(this.statsBar); this._updateStats(); } - + // Add container to element this.element.appendChild(this.container); - + // Debug logging if (window.location.pathname.includes('demo.html')) { console.log('_createDOM completed:', { @@ -383,14 +384,14 @@ class OverType { hasToolbar: this.options.toolbar }); } - + // Setup auto-resize if enabled if (this.options.autoResize) { this._setupAutoResize(); } else { // Ensure auto-resize class is removed if not using auto-resize this.container.classList.remove('overtype-auto-resize'); - + if (window.location.pathname.includes('demo.html')) { console.log('Removed auto-resize class from:', this.element.id); } @@ -420,7 +421,7 @@ class OverType { if (this.options.autofocus) { this.textarea.focus(); } - + // Setup or remove auto-resize if (this.options.autoResize) { if (!this.container.classList.contains('overtype-auto-resize')) { @@ -442,21 +443,21 @@ class OverType { const text = this.textarea.value; const cursorPos = this.textarea.selectionStart; const activeLine = this._getCurrentLine(text, cursorPos); - + // Parse markdown - const html = MarkdownParser.parse(text, activeLine, this.options.showActiveLineRaw); + const html = MarkdownParser.parse(text, activeLine, this.options.showActiveLineRaw, this.options.codeHighlighter); this.preview.innerHTML = html || 'Start typing...'; - + // Apply code block backgrounds this._applyCodeBlockBackgrounds(); - + // Links always have real hrefs now - no need to update them - + // Update stats if enabled if (this.options.showStats && this.statsBar) { this._updateStats(); } - + // Trigger onChange callback if (this.options.onChange && this.initialized) { this.options.onChange(text, this); @@ -470,26 +471,26 @@ class OverType { _applyCodeBlockBackgrounds() { // Find all code fence elements const codeFences = this.preview.querySelectorAll('.code-fence'); - + // Process pairs of code fences for (let i = 0; i < codeFences.length - 1; i += 2) { const openFence = codeFences[i]; const closeFence = codeFences[i + 1]; - + // Get parent divs const openParent = openFence.parentElement; const closeParent = closeFence.parentElement; - + if (!openParent || !closeParent) continue; - + // Make fences display: block openFence.style.display = 'block'; closeFence.style.display = 'block'; - + // Apply class to parent divs openParent.classList.add('code-block-line'); closeParent.classList.add('code-block-line'); - + // With the new structure, there's a
 block between fences, not DIVs
         // We don't need to process anything between the fences anymore
         // The 
 structure already handles the content correctly
@@ -521,21 +522,21 @@ class OverType {
       // Handle Tab key to prevent focus loss and insert spaces
       if (event.key === 'Tab') {
         event.preventDefault();
-        
+
         const start = this.textarea.selectionStart;
         const end = this.textarea.selectionEnd;
         const value = this.textarea.value;
-        
+
         // If there's a selection, indent/outdent based on shift key
         if (start !== end && event.shiftKey) {
           // Outdent: remove 2 spaces from start of each selected line
           const before = value.substring(0, start);
           const selection = value.substring(start, end);
           const after = value.substring(end);
-          
+
           const lines = selection.split('\n');
           const outdented = lines.map(line => line.replace(/^  /, '')).join('\n');
-          
+
           // Try to use execCommand first to preserve undo history
           if (document.execCommand) {
             // Select the text that needs to be replaced
@@ -552,10 +553,10 @@ class OverType {
           const before = value.substring(0, start);
           const selection = value.substring(start, end);
           const after = value.substring(end);
-          
+
           const lines = selection.split('\n');
           const indented = lines.map(line => '  ' + line).join('\n');
-          
+
           // Try to use execCommand first to preserve undo history
           if (document.execCommand) {
             // Select the text that needs to be replaced
@@ -578,12 +579,12 @@ class OverType {
             this.textarea.selectionStart = this.textarea.selectionEnd = start + 2;
           }
         }
-        
+
         // Trigger input event to update preview
         this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
         return;
       }
-      
+
       // Handle Enter key for smart list continuation
       if (event.key === 'Enter' && !event.shiftKey && !event.metaKey && !event.ctrlKey && this.options.smartLists) {
         if (this.handleSmartListContinuation()) {
@@ -591,10 +592,10 @@ class OverType {
           return;
         }
       }
-      
+
       // Let shortcuts manager handle other keys
       const handled = this.shortcuts.handleKeydown(event);
-      
+
       // Call user callback if provided
       if (!handled && this.options.onKeydown) {
         this.options.onKeydown(event, this);
@@ -609,15 +610,15 @@ class OverType {
       const textarea = this.textarea;
       const cursorPos = textarea.selectionStart;
       const context = MarkdownParser.getListContext(textarea.value, cursorPos);
-      
+
       if (!context || !context.inList) return false;
-      
+
       // Handle empty list item (exit list)
       if (context.content.trim() === '' && cursorPos >= context.markerEndPos) {
         this.deleteListMarker(context);
         return true;
       }
-      
+
       // Handle text splitting if cursor is in middle of content
       if (cursorPos > context.markerEndPos && cursorPos < context.lineEnd) {
         this.splitListItem(context, cursorPos);
@@ -625,15 +626,15 @@ class OverType {
         // Just add new item after current line
         this.insertNewListItem(context);
       }
-      
+
       // Handle numbered list renumbering
       if (context.listType === 'numbered') {
         this.scheduleNumberedListUpdate();
       }
-      
+
       return true;
     }
-    
+
     /**
      * Delete list marker and exit list
      * @private
@@ -642,11 +643,11 @@ class OverType {
       // Select from line start to marker end
       this.textarea.setSelectionRange(context.lineStart, context.markerEndPos);
       document.execCommand('delete');
-      
+
       // Trigger input event
       this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
     }
-    
+
     /**
      * Insert new list item
      * @private
@@ -654,11 +655,11 @@ class OverType {
     insertNewListItem(context) {
       const newItem = MarkdownParser.createNewListItem(context);
       document.execCommand('insertText', false, '\n' + newItem);
-      
+
       // Trigger input event
       this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
     }
-    
+
     /**
      * Split list item at cursor position
      * @private
@@ -666,23 +667,23 @@ class OverType {
     splitListItem(context, cursorPos) {
       // Get text after cursor
       const textAfterCursor = context.content.substring(cursorPos - context.markerEndPos);
-      
+
       // Delete text after cursor
       this.textarea.setSelectionRange(cursorPos, context.lineEnd);
       document.execCommand('delete');
-      
+
       // Insert new list item with remaining text
       const newItem = MarkdownParser.createNewListItem(context);
       document.execCommand('insertText', false, '\n' + newItem + textAfterCursor);
-      
+
       // Position cursor after new list marker
       const newCursorPos = this.textarea.selectionStart - textAfterCursor.length;
       this.textarea.setSelectionRange(newCursorPos, newCursorPos);
-      
+
       // Trigger input event
       this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
     }
-    
+
     /**
      * Schedule numbered list renumbering
      * @private
@@ -692,13 +693,13 @@ class OverType {
       if (this.numberUpdateTimeout) {
         clearTimeout(this.numberUpdateTimeout);
       }
-      
+
       // Schedule update after current input cycle
       this.numberUpdateTimeout = setTimeout(() => {
         this.updateNumberedLists();
       }, 10);
     }
-    
+
     /**
      * Update/renumber all numbered lists
      * @private
@@ -706,16 +707,16 @@ class OverType {
     updateNumberedLists() {
       const value = this.textarea.value;
       const cursorPos = this.textarea.selectionStart;
-      
+
       const newValue = MarkdownParser.renumberLists(value);
-      
+
       if (newValue !== value) {
         // Calculate cursor offset
         let offset = 0;
         const oldLines = value.split('\n');
         const newLines = newValue.split('\n');
         let charCount = 0;
-        
+
         for (let i = 0; i < oldLines.length && charCount < cursorPos; i++) {
           if (oldLines[i] !== newLines[i]) {
             const diff = newLines[i].length - oldLines[i].length;
@@ -725,12 +726,12 @@ class OverType {
           }
           charCount += oldLines[i].length + 1; // +1 for newline
         }
-        
+
         // Update textarea
         this.textarea.value = newValue;
         const newCursorPos = cursorPos + offset;
         this.textarea.setSelectionRange(newCursorPos, newCursorPos);
-        
+
         // Trigger update
         this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
       }
@@ -761,7 +762,7 @@ class OverType {
     setValue(value) {
       this.textarea.value = value;
       this.updatePreview();
-      
+
       // Update height if auto-resize is enabled
       if (this.options.autoResize) {
         this._updateAutoHeight();
@@ -776,13 +777,13 @@ class OverType {
      */
     getRenderedHTML(processForPreview = false) {
       const markdown = this.getValue();
-      let html = MarkdownParser.parse(markdown);
-      
+      let html = MarkdownParser.parse(markdown, -1, false, this.options.codeHighlighter);
+
       if (processForPreview) {
         // Post-process HTML for preview mode
-        html = MarkdownParser.postProcessHTML(html);
+        html = MarkdownParser.postProcessHTML(html, this.options.codeHighlighter);
       }
-      
+
       return html;
     }
 
@@ -826,25 +827,34 @@ class OverType {
       this.updatePreview();
     }
 
+    /**
+     * Set instance-specific code highlighter
+     * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML
+     */
+    setCodeHighlighter(highlighter) {
+      this.options.codeHighlighter = highlighter;
+      this.updatePreview();
+    }
+
     /**
      * Update stats bar
      * @private
      */
     _updateStats() {
       if (!this.statsBar) return;
-      
+
       const value = this.textarea.value;
       const lines = value.split('\n');
       const chars = value.length;
       const words = value.split(/\s+/).filter(w => w.length > 0).length;
-      
+
       // Calculate line and column
       const selectionStart = this.textarea.selectionStart;
       const beforeCursor = value.substring(0, selectionStart);
       const linesBeforeCursor = beforeCursor.split('\n');
       const currentLine = linesBeforeCursor.length;
       const currentColumn = linesBeforeCursor[linesBeforeCursor.length - 1].length + 1;
-      
+
       // Use custom formatter if provided
       if (this.options.statsFormatter) {
         this.statsBar.innerHTML = this.options.statsFormatter({
@@ -865,7 +875,7 @@ class OverType {
         `;
       }
     }
-    
+
     /**
      * Setup auto-resize functionality
      * @private
@@ -873,51 +883,51 @@ class OverType {
     _setupAutoResize() {
       // Add auto-resize class for styling
       this.container.classList.add('overtype-auto-resize');
-      
+
       // Store previous height for comparison
       this.previousHeight = null;
-      
+
       // Initial height update
       this._updateAutoHeight();
-      
+
       // Listen for input events
       this.textarea.addEventListener('input', () => this._updateAutoHeight());
-      
+
       // Listen for window resize
       window.addEventListener('resize', () => this._updateAutoHeight());
     }
-    
+
     /**
      * Update height based on scrollHeight
      * @private
      */
     _updateAutoHeight() {
       if (!this.options.autoResize) return;
-      
+
       const textarea = this.textarea;
       const preview = this.preview;
       const wrapper = this.wrapper;
-      
+
       // Get computed styles
       const computed = window.getComputedStyle(textarea);
       const paddingTop = parseFloat(computed.paddingTop);
       const paddingBottom = parseFloat(computed.paddingBottom);
-      
+
       // Store scroll positions
       const scrollTop = textarea.scrollTop;
-      
+
       // Reset height to get accurate scrollHeight
       textarea.style.setProperty('height', 'auto', 'important');
-      
+
       // Calculate new height based on scrollHeight
       let newHeight = textarea.scrollHeight;
-      
+
       // Apply min height constraint
       if (this.options.minHeight) {
         const minHeight = parseInt(this.options.minHeight);
         newHeight = Math.max(newHeight, minHeight);
       }
-      
+
       // Apply max height constraint
       let overflow = 'hidden';
       if (this.options.maxHeight) {
@@ -927,35 +937,35 @@ class OverType {
           overflow = 'auto';
         }
       }
-      
+
       // Apply the new height to all elements with !important to override base styles
       const heightPx = newHeight + 'px';
       textarea.style.setProperty('height', heightPx, 'important');
       textarea.style.setProperty('overflow-y', overflow, 'important');
-      
+
       preview.style.setProperty('height', heightPx, 'important');
       preview.style.setProperty('overflow-y', overflow, 'important');
-      
+
       wrapper.style.setProperty('height', heightPx, 'important');
-      
+
       // Restore scroll position
       textarea.scrollTop = scrollTop;
       preview.scrollTop = scrollTop;
-      
+
       // Track if height changed
       if (this.previousHeight !== newHeight) {
         this.previousHeight = newHeight;
         // Could dispatch a custom event here if needed
       }
     }
-    
+
     /**
      * Show or hide stats bar
      * @param {boolean} show - Whether to show stats
      */
     showStats(show) {
       this.options.showStats = show;
-      
+
       if (show && !this.statsBar) {
         // Create stats bar (add to container, not wrapper)
         this.statsBar = document.createElement('div');
@@ -968,7 +978,7 @@ class OverType {
         this.statsBar = null;
       }
     }
-    
+
     /**
      * Show or hide the plain textarea (toggle overlay visibility)
      * @param {boolean} show - true to show plain textarea (hide overlay), false to show overlay
@@ -982,7 +992,7 @@ class OverType {
         // Show overlay mode (hide plain textarea text)
         this.container.classList.remove('plain-mode');
       }
-      
+
       // Update toolbar button if exists
       if (this.toolbar) {
         const toggleBtn = this.container.querySelector('[data-action="toggle-plain"]');
@@ -992,7 +1002,7 @@ class OverType {
           toggleBtn.title = show ? 'Show markdown preview' : 'Show plain textarea';
         }
       }
-      
+
       return show;
     }
 
@@ -1009,7 +1019,7 @@ class OverType {
         // Show edit mode
         this.container.classList.remove('preview-mode');
       }
-      
+
       return show;
     }
 
@@ -1030,7 +1040,7 @@ class OverType {
       if (this.wrapper) {
         const content = this.getValue();
         this.wrapper.remove();
-        
+
         // Restore original content
         this.element.textContent = content;
       }
@@ -1095,7 +1105,23 @@ class OverType {
 
       OverType.stylesInjected = true;
     }
-    
+
+    /**
+     * Set global code highlighter for all OverType instances
+     * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML
+     */
+    static setCodeHighlighter(highlighter) {
+      MarkdownParser.setCodeHighlighter(highlighter);
+
+      // Update all existing instances
+      document.querySelectorAll('.overtype-wrapper').forEach(wrapper => {
+        const instance = wrapper._instance;
+        if (instance && instance.updatePreview) {
+          instance.updatePreview();
+        }
+      });
+    }
+
     /**
      * Set global theme for all OverType instances
      * @param {string|Object} theme - Theme name or custom theme object
@@ -1104,18 +1130,18 @@ class OverType {
     static setTheme(theme, customColors = null) {
       // Process theme
       let themeObj = typeof theme === 'string' ? getTheme(theme) : theme;
-      
+
       // Apply custom colors if provided
       if (customColors) {
         themeObj = mergeTheme(themeObj, customColors);
       }
-      
+
       // Store as current theme
       OverType.currentTheme = themeObj;
-      
+
       // Re-inject styles with new theme
       OverType.injectStyles(true);
-      
+
       // Update all existing instances - update container theme attribute
       document.querySelectorAll('.overtype-container').forEach(container => {
         const themeName = typeof themeObj === 'string' ? themeObj : themeObj.name;
@@ -1123,7 +1149,7 @@ class OverType {
           container.setAttribute('data-theme', themeName);
         }
       });
-      
+
       // Also handle any old-style wrappers without containers
       document.querySelectorAll('.overtype-wrapper').forEach(wrapper => {
         if (!wrapper.closest('.overtype-container')) {
@@ -1132,7 +1158,7 @@ class OverType {
             wrapper.setAttribute('data-theme', themeName);
           }
         }
-        
+
         // Trigger preview update for the instance
         const instance = wrapper._instance;
         if (instance) {
@@ -1217,4 +1243,4 @@ if (typeof window !== 'undefined' && typeof window.document !== 'undefined') {
 
 // Export for module systems
 export default OverType;
-export { OverType };
\ No newline at end of file
+export { OverType };
diff --git a/src/parser.js b/src/parser.js
index 36284ee..a6f6ef0 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -1,6 +1,6 @@
 /**
  * MarkdownParser - Parses markdown into HTML while preserving character alignment
- * 
+ *
  * Key principles:
  * - Every character must occupy the exact same position as in the textarea
  * - No font-size changes, no padding/margin on inline elements
@@ -9,14 +9,25 @@
 export class MarkdownParser {
   // Track link index for anchor naming
   static linkIndex = 0;
-  
+
+  // Global code highlighter function
+  static codeHighlighter = null;
+
   /**
    * Reset link index (call before parsing a new document)
    */
   static resetLinkIndex() {
     this.linkIndex = 0;
   }
-  
+
+  /**
+   * Set global code highlighter function
+   * @param {Function|null} highlighter - Function that takes (code, language) and returns highlighted HTML
+   */
+  static setCodeHighlighter(highlighter) {
+    this.codeHighlighter = highlighter;
+  }
+
   /**
    * Escape HTML special characters
    * @param {string} text - Raw text to escape
@@ -165,7 +176,7 @@ export class MarkdownParser {
     // Trim whitespace and convert to lowercase for protocol check
     const trimmed = url.trim();
     const lower = trimmed.toLowerCase();
-    
+
     // Allow safe protocols
     const safeProtocols = [
       'http://',
@@ -174,22 +185,22 @@ export class MarkdownParser {
       'ftp://',
       'ftps://'
     ];
-    
+
     // Check if URL starts with a safe protocol
     const hasSafeProtocol = safeProtocols.some(protocol => lower.startsWith(protocol));
-    
+
     // Allow relative URLs (starting with / or # or no protocol)
-    const isRelative = trimmed.startsWith('/') || 
-                      trimmed.startsWith('#') || 
+    const isRelative = trimmed.startsWith('/') ||
+                      trimmed.startsWith('#') ||
                       trimmed.startsWith('?') ||
                       trimmed.startsWith('.') ||
                       (!trimmed.includes(':') && !trimmed.includes('//'));
-    
+
     // If safe protocol or relative URL, return as-is
     if (hasSafeProtocol || isRelative) {
       return url;
     }
-    
+
     // Block dangerous protocols (javascript:, data:, vbscript:, etc.)
     return '#';
   }
@@ -218,7 +229,7 @@ export class MarkdownParser {
     let html = text;
     // Order matters: parse code first
     html = this.parseInlineCode(html);
-    
+
     // Use placeholders to protect inline code while preserving formatting spans
     // We use Unicode Private Use Area (U+E000-U+F8FF) as placeholders because:
     // 1. These characters are reserved for application-specific use
@@ -226,34 +237,34 @@ export class MarkdownParser {
     // 3. They maintain single-character width (important for alignment)
     // 4. They're invisible if accidentally rendered
     const sanctuaries = new Map();
-    
+
     // Protect code blocks
     html = html.replace(/(.*?<\/code>)/g, (match) => {
       const placeholder = `\uE000${sanctuaries.size}\uE001`;
       sanctuaries.set(placeholder, match);
       return placeholder;
     });
-    
+
     // Parse links AFTER protecting code but BEFORE bold/italic
     // This ensures link URLs don't get processed as markdown
     html = this.parseLinks(html);
-    
+
     // Protect entire link elements (not just the URL part)
     html = html.replace(/(]*>.*?<\/a>)/g, (match) => {
       const placeholder = `\uE000${sanctuaries.size}\uE001`;
       sanctuaries.set(placeholder, match);
       return placeholder;
     });
-    
+
     // Process other inline elements on text with placeholders
     html = this.parseBold(html);
     html = this.parseItalic(html);
-    
+
     // Restore all sanctuaries
     sanctuaries.forEach((content, placeholder) => {
       html = html.replace(placeholder, content);
     });
-    
+
     return html;
   }
 
@@ -264,33 +275,33 @@ export class MarkdownParser {
    */
   static parseLine(line) {
     let html = this.escapeHtml(line);
-    
+
     // Preserve indentation
     html = this.preserveIndentation(html, line);
-    
+
     // Check for block elements first
     const horizontalRule = this.parseHorizontalRule(html);
     if (horizontalRule) return horizontalRule;
-    
+
     const codeBlock = this.parseCodeBlock(html);
     if (codeBlock) return codeBlock;
-    
+
     // Parse block elements
     html = this.parseHeader(html);
     html = this.parseBlockquote(html);
     html = this.parseBulletList(html);
     html = this.parseNumberedList(html);
-    
+
     // Parse inline elements
     html = this.parseInlineElements(html);
-    
+
     // Wrap in div to maintain line structure
     if (html.trim() === '') {
       // Intentionally use   for empty lines to maintain vertical spacing
       // This causes a 0->1 character count difference but preserves visual alignment
       return '
 
'; } - + return `
${html}
`; } @@ -299,22 +310,23 @@ export class MarkdownParser { * @param {string} text - Full markdown text * @param {number} activeLine - Currently active line index (optional) * @param {boolean} showActiveLineRaw - Show raw markdown on active line + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) * @returns {string} Parsed HTML */ - static parse(text, activeLine = -1, showActiveLineRaw = false) { + static parse(text, activeLine = -1, showActiveLineRaw = false, instanceHighlighter = null) { // Reset link counter for each parse this.resetLinkIndex(); - + const lines = text.split('\n'); let inCodeBlock = false; - + const parsedLines = lines.map((line, index) => { // Show raw markdown on active line if requested if (showActiveLineRaw && index === activeLine) { const content = this.escapeHtml(line) || ' '; return `
${content}
`; } - + // Check if this line is a code fence const codeFenceRegex = /^```[^`]*$/; if (codeFenceRegex.test(line)) { @@ -322,55 +334,56 @@ export class MarkdownParser { // Parse fence markers normally to get styled output return this.parseLine(line); } - + // If we're inside a code block, don't parse as markdown if (inCodeBlock) { const escaped = this.escapeHtml(line); const indented = this.preserveIndentation(escaped, line); return `
${indented || ' '}
`; } - + // Otherwise, parse the markdown normally return this.parseLine(line); }); - + // Join without newlines to prevent extra spacing const html = parsedLines.join(''); - + // Apply post-processing for list consolidation - return this.postProcessHTML(html); + return this.postProcessHTML(html, instanceHighlighter); } /** * Post-process HTML to consolidate lists and code blocks * @param {string} html - HTML to post-process + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) * @returns {string} Post-processed HTML with consolidated lists and code blocks */ - static postProcessHTML(html) { + static postProcessHTML(html, instanceHighlighter = null) { // Check if we're in a browser environment if (typeof document === 'undefined' || !document) { // In Node.js environment - do manual post-processing - return this.postProcessHTMLManual(html); + return this.postProcessHTMLManual(html, instanceHighlighter); } - + // Parse HTML string into DOM const container = document.createElement('div'); container.innerHTML = html; - + let currentList = null; let listType = null; let currentCodeBlock = null; let inCodeBlock = false; - + // Process all direct children - need to be careful with live NodeList const children = Array.from(container.children); - + for (let i = 0; i < children.length; i++) { const child = children[i]; - + // Skip if child was already processed/removed if (!child.parentNode) continue; - + // Check for code fence start/end const codeFence = child.querySelector('.code-fence'); if (codeFence) { @@ -379,79 +392,99 @@ export class MarkdownParser { if (!inCodeBlock) { // Start of code block - keep fence visible, then add pre/code inCodeBlock = true; - + // Create the code block that will follow the fence currentCodeBlock = document.createElement('pre'); const codeElement = document.createElement('code'); currentCodeBlock.appendChild(codeElement); currentCodeBlock.className = 'code-block'; - + // Extract language if present const lang = fenceText.slice(3).trim(); if (lang) { codeElement.className = `language-${lang}`; } - + // Insert code block after the fence div (don't remove the fence) container.insertBefore(currentCodeBlock, child.nextSibling); - + // Store reference to the code element for adding content currentCodeBlock._codeElement = codeElement; + currentCodeBlock._language = lang; + currentCodeBlock._codeContent = ''; continue; } else { - // End of code block - fence stays visible + // End of code block - apply highlighting if needed + const highlighter = instanceHighlighter || this.codeHighlighter; + if (currentCodeBlock && highlighter && currentCodeBlock._codeContent) { + try { + const highlightedCode = highlighter( + currentCodeBlock._codeContent, + currentCodeBlock._language || '' + ); + currentCodeBlock._codeElement.innerHTML = highlightedCode; + } catch (error) { + console.warn('Code highlighting failed:', error); + // Keep the plain text content as fallback + } + } + inCodeBlock = false; currentCodeBlock = null; continue; } } } - + // Check if we're in a code block - any div that's not a code fence if (inCodeBlock && currentCodeBlock && child.tagName === 'DIV' && !child.querySelector('.code-fence')) { const codeElement = currentCodeBlock._codeElement || currentCodeBlock.querySelector('code'); - // Add the line content to the code block - if (codeElement.textContent.length > 0) { - codeElement.textContent += '\n'; + // Add the line content to the code block content (for highlighting) + if (currentCodeBlock._codeContent.length > 0) { + currentCodeBlock._codeContent += '\n'; } // Get the actual text content, preserving spaces - // Use textContent instead of innerHTML to avoid double-escaping - // textContent automatically decodes HTML entities const lineText = child.textContent.replace(/\u00A0/g, ' '); // \u00A0 is nbsp + currentCodeBlock._codeContent += lineText; + + // Also add to the code element (fallback if no highlighter) + if (codeElement.textContent.length > 0) { + codeElement.textContent += '\n'; + } codeElement.textContent += lineText; child.remove(); continue; } - + // Check if this div contains a list item let listItem = null; if (child.tagName === 'DIV') { // Look for li inside the div listItem = child.querySelector('li'); } - + if (listItem) { const isBullet = listItem.classList.contains('bullet-list'); const isOrdered = listItem.classList.contains('ordered-list'); - + if (!isBullet && !isOrdered) { currentList = null; listType = null; continue; } - + const newType = isBullet ? 'ul' : 'ol'; - + // Start new list or continue current if (!currentList || listType !== newType) { currentList = document.createElement(newType); container.insertBefore(currentList, child); listType = newType; } - + // Move the list item to the current list currentList.appendChild(listItem); - + // Remove the now-empty div wrapper child.remove(); } else { @@ -460,18 +493,19 @@ export class MarkdownParser { listType = null; } } - + return container.innerHTML; } /** * Manual post-processing for Node.js environments (without DOM) * @param {string} html - HTML to post-process + * @param {Function} instanceHighlighter - Instance-specific code highlighter (optional) * @returns {string} Post-processed HTML */ - static postProcessHTMLManual(html) { + static postProcessHTMLManual(html, instanceHighlighter = null) { let processed = html; - + // Process unordered lists processed = processed.replace(/((?:
(?: )*
  • .*?<\/li><\/div>\s*)+)/gs, (match) => { const items = match.match(/
  • .*?<\/li>/gs) || []; @@ -480,7 +514,7 @@ export class MarkdownParser { } return match; }); - + // Process ordered lists processed = processed.replace(/((?:
    (?: )*
  • .*?<\/li><\/div>\s*)+)/gs, (match) => { const items = match.match(/
  • .*?<\/li>/gs) || []; @@ -489,7 +523,7 @@ export class MarkdownParser { } return match; }); - + // Process code blocks - KEEP the fence markers for alignment AND use semantic pre/code const codeBlockRegex = /
    (```[^<]*)<\/span><\/div>(.*?)
    (```)<\/span><\/div>/gs; processed = processed.replace(codeBlockRegex, (match, openFence, content, closeFence) => { @@ -501,20 +535,32 @@ export class MarkdownParser { .replace(/ /g, ' '); return text; }).join('\n'); - + // Extract language from the opening fence const lang = openFence.slice(3).trim(); const langClass = lang ? ` class="language-${lang}"` : ''; - + + // Apply code highlighting if available + let highlightedContent = codeContent; + const highlighter = instanceHighlighter || this.codeHighlighter; + if (highlighter) { + try { + highlightedContent = highlighter(codeContent, lang); + } catch (error) { + console.warn('Code highlighting failed:', error); + // Fall back to original content + } + } + // Keep fence markers visible as separate divs, with pre/code block between them let result = `
    ${openFence}
    `; - // Content is already escaped, don't double-escape - result += `
    ${codeContent}
    `; + // Use highlighted content if available, otherwise use escaped content + result += `
    ${highlightedContent}
    `; result += `
    ${closeFence}
    `; - + return result; }); - + return processed; } @@ -539,7 +585,7 @@ export class MarkdownParser { let currentPos = 0; let lineIndex = 0; let lineStart = 0; - + for (let i = 0; i < lines.length; i++) { const lineLength = lines[i].length; if (currentPos + lineLength >= cursorPosition) { @@ -549,10 +595,10 @@ export class MarkdownParser { } currentPos += lineLength + 1; // +1 for newline } - + const currentLine = lines[lineIndex]; const lineEnd = lineStart + currentLine.length; - + // Check for checkbox first (most specific) const checkboxMatch = currentLine.match(this.LIST_PATTERNS.checkbox); if (checkboxMatch) { @@ -568,7 +614,7 @@ export class MarkdownParser { markerEndPos: lineStart + checkboxMatch[1].length + checkboxMatch[2].length + 5 // indent + "- [ ] " }; } - + // Check for bullet list const bulletMatch = currentLine.match(this.LIST_PATTERNS.bullet); if (bulletMatch) { @@ -583,7 +629,7 @@ export class MarkdownParser { markerEndPos: lineStart + bulletMatch[1].length + bulletMatch[2].length + 1 // indent + marker + space }; } - + // Check for numbered list const numberedMatch = currentLine.match(this.LIST_PATTERNS.numbered); if (numberedMatch) { @@ -598,7 +644,7 @@ export class MarkdownParser { markerEndPos: lineStart + numberedMatch[1].length + numberedMatch[2].length + 2 // indent + number + ". " }; } - + // Not in a list return { inList: false, @@ -639,31 +685,31 @@ export class MarkdownParser { const lines = text.split('\n'); const numbersByIndent = new Map(); let inList = false; - + const result = lines.map(line => { const match = line.match(this.LIST_PATTERNS.numbered); - + if (match) { const indent = match[1]; const indentLevel = indent.length; const content = match[3]; - + // If we weren't in a list or indent changed, reset lower levels if (!inList) { numbersByIndent.clear(); } - + // Get the next number for this indent level const currentNumber = (numbersByIndent.get(indentLevel) || 0) + 1; numbersByIndent.set(indentLevel, currentNumber); - + // Clear deeper indent levels for (const [level] of numbersByIndent) { if (level > indentLevel) { numbersByIndent.delete(level); } } - + inList = true; return `${indent}${currentNumber}. ${content}`; } else { @@ -676,7 +722,7 @@ export class MarkdownParser { return line; } }); - + return result.join('\n'); } -} \ No newline at end of file +} From cf898639c0e89c78cd30af8d129d91605c822fe0 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Sat, 23 Aug 2025 16:37:14 +0800 Subject: [PATCH 2/4] feat: add shiki.js and highlight.js examples --- examples/highlightjs-integration.html | 2220 +++++++++++++++++++++++++ examples/shiki-integration.html | 1637 ++++++++++++++++++ 2 files changed, 3857 insertions(+) create mode 100644 examples/highlightjs-integration.html create mode 100644 examples/shiki-integration.html diff --git a/examples/highlightjs-integration.html b/examples/highlightjs-integration.html new file mode 100644 index 0000000..0b1dfba --- /dev/null +++ b/examples/highlightjs-integration.html @@ -0,0 +1,2220 @@ + + + + + + OverType + highlight.js Integration + + + + + + + +

    OverType + highlight.js Integration

    +

    Real-time syntax highlighting with highlight.js auto-detection and 180+ languages

    + +
    + +
    +
    + + +
    + + + +
    + +
    Initializing highlight.js syntax highlighting...
    + +
    +

    About highlight.js Integration

    +

    This example demonstrates OverType's integration with highlight.js, a popular syntax highlighting library:

    + +
    +
    +

    🎨 Themes

    +

    Choose from dozens of color themes to match your design

    +
    +
    +

    🔍 Auto-Detection

    +

    Automatically detects language when not specified

    +
    +
    +

    📚 180+ Languages

    +

    Supports virtually every programming language

    +
    +
    +

    ⚡ Performance

    +

    Fast highlighting with minimal overhead

    +
    +
    +

    🔧 Easy Setup

    +

    Simple CDN integration, no build process required

    +
    +
    +

    📱 Universal

    +

    Works in all modern browsers and environments

    +
    +
    + +

    Implementation

    +

    The integration uses OverType's setCodeHighlighter API with highlight.js's programmatic API for real-time highlighting as you type.

    +
    + + + + + + + + + + + + + + + + + + diff --git a/examples/shiki-integration.html b/examples/shiki-integration.html new file mode 100644 index 0000000..a20612d --- /dev/null +++ b/examples/shiki-integration.html @@ -0,0 +1,1637 @@ + + + + + + OverType + Shiki.js Integration + + + +

    OverType + Shiki.js Integration

    +

    Real-time syntax highlighting powered by Shiki's TextMate grammar engine

    + +
    + +
    + + + +
    + +
    Initializing Shiki syntax highlighter...
    + +
    +

    About This Integration

    +

    This example demonstrates how to integrate Shiki.js with OverType for professional-grade syntax highlighting:

    +
      +
    • TextMate Grammars: Uses the same syntax highlighting as VS Code
    • +
    • Multiple Themes: Supports dozens of color themes
    • +
    • Language Support: 100+ programming languages
    • +
    • Real-time: Highlights code as you type
    • +
    • Preserves Alignment: Maintains perfect character positioning
    • +
    +

    Implementation: The integration uses OverType's setCodeHighlighter API to provide a custom highlighting function that calls Shiki's core tokenizer.

    +
    + + + + From 938a691b17ef4b6040698aa724c91cfd155ba132 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Mon, 8 Sep 2025 17:03:40 +0800 Subject: [PATCH 3/4] chore: fixing test:types script --- src/overtype.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/overtype.d.ts b/src/overtype.d.ts index c733d7b..426e6f6 100644 --- a/src/overtype.d.ts +++ b/src/overtype.d.ts @@ -2,6 +2,8 @@ // Project: https://github.com/panphora/overtype // Definitions generated from JSDoc comments and implementation +/// + export interface Theme { name: string; colors: { From c516220df8ab356e6a3b6bafdf228f1e22419c74 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Mon, 8 Sep 2025 17:12:07 +0800 Subject: [PATCH 4/4] fix: infinite loop re-rendering to overtype-preview --- src/link-tooltip.js | 3 +-- src/overtype.js | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/link-tooltip.js b/src/link-tooltip.js index 44f66dd..4deb92b 100644 --- a/src/link-tooltip.js +++ b/src/link-tooltip.js @@ -28,8 +28,7 @@ export class LinkTooltip { // Create tooltip element this.createTooltip(); - // Listen for cursor position changes - this.editor.textarea.addEventListener('selectionchange', () => this.checkCursorPosition()); + // Note: selectionchange is handled at document level in global listeners this.editor.textarea.addEventListener('keyup', (e) => { if (e.key.includes('Arrow') || e.key === 'Home' || e.key === 'End') { this.checkCursorPosition(); diff --git a/src/overtype.js b/src/overtype.js index b1791c7..f6ff00a 100644 --- a/src/overtype.js +++ b/src/overtype.js @@ -108,10 +108,7 @@ class OverType { this.toolbar = new Toolbar(this, toolbarButtons); this.toolbar.create(); - // Update toolbar states on selection change - this.textarea.addEventListener('selectionchange', () => { - this.toolbar.updateButtonStates(); - }); + // Note: selectionchange only works on document level, handled in global listeners this.textarea.addEventListener('input', () => { this.toolbar.updateButtonStates(); }); @@ -1227,11 +1224,22 @@ class OverType { if (instance.options.showStats && instance.statsBar) { instance._updateStats(); } - // Debounce updates - clearTimeout(instance._selectionTimeout); - instance._selectionTimeout = setTimeout(() => { - instance.updatePreview(); - }, 50); + // Update toolbar button states if toolbar exists + if (instance.toolbar && instance.toolbar.updateButtonStates) { + instance.toolbar.updateButtonStates(); + } + // Update link tooltip position if it exists + if (instance.linkTooltip && instance.linkTooltip.checkCursorPosition) { + instance.linkTooltip.checkCursorPosition(); + } + // Only update preview if showing active line raw (which depends on cursor position) + if (instance.options.showActiveLineRaw) { + // Debounce updates + clearTimeout(instance._selectionTimeout); + instance._selectionTimeout = setTimeout(() => { + instance.updatePreview(); + }, 50); + } } } });