update codeblock

This commit is contained in:
Juergen Kunz
2025-06-26 11:41:58 +00:00
parent 4a26307e1b
commit 09e35d0245
3 changed files with 1038 additions and 359 deletions

View File

@ -0,0 +1,608 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
import hlight from 'highlight.js';
export class CodeBlockHandler extends BaseBlockHandler {
type = 'code';
// Track cursor position
private lastKnownCursorPosition: number = 0;
// Debounce timer for highlighting
private highlightingTimer: any = null;
render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'javascript';
const selectedClass = isSelected ? ' selected' : '';
return `
<div class="code-block-container${selectedClass}">
<div class="code-language">${language}</div>
<div class="code-grid">
<div class="line-numbers"></div>
<div
class="block code"
contenteditable="true"
data-block-id="${block.id}"
data-block-type="${block.type}"
spellcheck="false"
></div>
</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
console.error('CodeBlockHandler.setup: No code block element found');
return;
}
// Set initial content if needed - use textContent for code blocks
if (block.content && !codeBlock.textContent) {
codeBlock.textContent = block.content;
}
// Apply initial highlighting
this.applyHighlighting(element, block);
// Variable to track if we're in composition mode
let isComposing = false;
// Input handler
codeBlock.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Update line numbers immediately
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLDivElement;
if (lineNumbersContainer) {
this.updateLineNumbers(codeBlock.textContent || '', lineNumbersContainer);
}
// Debounce highlighting to avoid performance issues while typing
if (!isComposing) {
clearTimeout(this.highlightingTimer);
this.highlightingTimer = setTimeout(() => {
// Store cursor position before highlighting
const currentPos = this.getCursorPosition(element);
// Get plain text before highlighting
const plainText = codeBlock.textContent || '';
// Apply highlighting
const language = block.metadata?.language || 'javascript';
try {
const result = hlight.highlight(plainText, {
language: language,
ignoreIllegals: true
});
codeBlock.innerHTML = result.value;
} catch (error) {
// Keep plain text if highlighting fails
}
// Restore cursor position
if (currentPos !== null && document.activeElement === codeBlock) {
WysiwygSelection.setCursorPosition(codeBlock, currentPos);
}
}, 500); // Wait 500ms after user stops typing
}
});
// Keydown handler
codeBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Tab key in code blocks
if (e.key === 'Tab') {
e.preventDefault();
// Insert two spaces for tab
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input'));
}
return;
}
handlers.onKeyDown(e);
});
// Focus handler
codeBlock.addEventListener('focus', () => {
handlers.onFocus();
});
// Blur handler
codeBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
codeBlock.addEventListener('compositionstart', () => {
isComposing = true;
handlers.onCompositionStart();
});
codeBlock.addEventListener('compositionend', () => {
isComposing = false;
handlers.onCompositionEnd();
// Apply highlighting after composition ends
setTimeout(() => {
this.applyHighlighting(element, block);
}, 100);
});
// Mouse up handler
codeBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
codeBlock.addEventListener('click', (_e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for cursor tracking
codeBlock.addEventListener('keyup', (_e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Paste handler - handle as plain text
codeBlock.addEventListener('paste', (e) => {
e.preventDefault();
const text = e.clipboardData?.getData('text/plain');
if (text) {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input'));
}
}
});
}
getStyles(): string {
return `
/* Code block specific styles */
.code-block-container {
position: relative;
margin: 20px 0;
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
}
.code-block-container.selected {
border-color: ${cssManager.bdTheme('#0066ff', '#4d9fff')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 255, 0.1)', 'rgba(77, 159, 255, 0.1)')};
}
.code-grid {
display: grid;
grid-template-columns: 50px 1fr;
overflow: hidden;
}
.line-numbers {
background: ${cssManager.bdTheme('#f3f3f3', '#0a0a0a')};
border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 12px 16px 0;
text-align: right;
user-select: none;
color: ${cssManager.bdTheme('#999999', '#666666')};
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.5;
}
.line-number {
height: 21px;
}
.line-number:last-child {
opacity: 0.5;
}
.block.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
padding: 16px 20px;
padding-top: 32px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 0;
min-height: 100px;
background: transparent;
border: none;
outline: none;
}
.code-language {
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
color: ${cssManager.bdTheme('#586069', '#8b949e')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 0 0 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
/* Highlight.js theme overrides */
.block.code .hljs-keyword {
color: ${cssManager.bdTheme('#d73a49', '#ff65ec')};
font-weight: normal;
}
.block.code .hljs-string {
color: ${cssManager.bdTheme('#032f62', '#ffa465')};
}
.block.code .hljs-number {
color: ${cssManager.bdTheme('#005cc5', '#65d5ff')};
}
.block.code .hljs-function {
color: ${cssManager.bdTheme('#6f42c1', '#6596ff')};
}
.block.code .hljs-comment {
color: ${cssManager.bdTheme('#6a737d', '#ffd765')};
font-style: italic;
}
.block.code .hljs-variable,
.block.code .hljs-attr {
color: ${cssManager.bdTheme('#e36209', '#65ff6a')};
}
.block.code .hljs-class,
.block.code .hljs-title {
color: ${cssManager.bdTheme('#6f42c1', '#65d5ff')};
}
.block.code .hljs-params {
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
}
.block.code .hljs-built_in {
color: ${cssManager.bdTheme('#005cc5', '#65ff6a')};
}
.block.code .hljs-literal {
color: ${cssManager.bdTheme('#005cc5', '#ff65ec')};
}
.block.code .hljs-meta {
color: ${cssManager.bdTheme('#735c0f', '#ffa465')};
}
`;
}
getPlaceholder(): string {
return '';
}
// Helper methods for code functionality
private applyHighlighting(element: HTMLElement, block: IBlock): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLDivElement;
if (!codeBlock || !lineNumbersContainer) return;
// Store current cursor position
const cursorPos = this.getCursorPosition(element);
// Get the plain text content
const plainText = codeBlock.textContent || '';
// Apply syntax highlighting
const language = block.metadata?.language || 'javascript';
let highlightedHtml: string;
try {
const result = hlight.highlight(plainText, {
language: language,
ignoreIllegals: true
});
highlightedHtml = result.value;
} catch (error) {
// Fallback to plain text if highlighting fails
highlightedHtml = this.escapeHtml(plainText);
}
// Update the code block with highlighted content
codeBlock.innerHTML = highlightedHtml;
// Update line numbers
this.updateLineNumbers(plainText, lineNumbersContainer);
// Restore cursor position if we had one
if (cursorPos !== null && document.activeElement === codeBlock) {
WysiwygSelection.setCursorPosition(codeBlock, cursorPos);
}
}
private updateLineNumbers(content: string, container: HTMLDivElement): void {
const lines = content.split('\n');
const lineCount = lines.length;
// Generate line numbers HTML
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
container.innerHTML = lineNumbersHtml;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual code element
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(codeBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, _context?: any): string {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return '';
// For code blocks, get textContent to avoid HTML formatting
const content = codeBlock.textContent || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === codeBlock ||
element.shadowRoot?.activeElement === codeBlock;
// Use textContent for code blocks
codeBlock.textContent = content;
// Apply highlighting
const block: IBlock = {
id: codeBlock.dataset.blockId || '',
type: 'code',
content: content,
metadata: context?.block?.metadata || {}
};
this.applyHighlighting(element, block);
// Restore focus if we had it
if (hadFocus) {
codeBlock.focus();
}
}
setCursorToStart(element: HTMLElement, _context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToStart(codeBlock);
}
}
setCursorToEnd(element: HTMLElement, _context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToEnd(codeBlock);
}
}
focus(element: HTMLElement, _context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure the element is focusable
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
codeBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== codeBlock && element.shadowRoot?.activeElement !== codeBlock) {
Promise.resolve().then(() => {
codeBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure element is focusable first
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
codeBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(codeBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: codeBlock.textContent || ''
};
}
// For code blocks, split based on text content only
const fullText = codeBlock.textContent || '';
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
};
}
}

View File

@ -1,81 +1,112 @@
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js'; import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element'; import { cssManager } from '@design.estate/dees-element';
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
import { WysiwygSelection } from '../../wysiwyg.selection.js'; import { WysiwygSelection } from '../../wysiwyg.selection.js';
import hlight from 'highlight.js';
/**
* New CodeBlockHandler with improved architecture
*
* Key improvements:
* 1. Simpler DOM structure
* 2. Better line number handling
* 3. Non-intrusive syntax highlighting
* 4. Cleaner event handling
*/
export class CodeBlockHandler extends BaseBlockHandler { export class CodeBlockHandler extends BaseBlockHandler {
type = 'code'; type = 'code';
// Track cursor position private highlightTimer: any = null;
private lastKnownCursorPosition: number = 0;
render(block: IBlock, isSelected: boolean): string { render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'plain text'; const language = block.metadata?.language || 'javascript';
const selectedClass = isSelected ? ' selected' : ''; const content = block.content || '';
const lineCount = content.split('\n').length;
// Generate line numbers
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
return ` return `
<div class="code-block-container"> <div class="code-block-container${isSelected ? ' selected' : ''}" data-language="${language}">
<div class="code-language">${language}</div> <div class="code-header">
<div <span class="language-label">${language}</span>
class="block code${selectedClass}" </div>
<div class="code-body">
<div class="line-numbers">${lineNumbersHtml}</div>
<div class="code-content">
<pre class="code-pre"><code class="code-editor"
contenteditable="true" contenteditable="true"
data-block-id="${block.id}" data-block-id="${block.id}"
data-block-type="${block.type}" data-block-type="${block.type}"
spellcheck="false" spellcheck="false">${this.escapeHtml(content)}</code></pre>
></div> </div>
</div>
</div> </div>
`; `;
} }
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement; const editor = element.querySelector('.code-editor') as HTMLElement;
if (!codeBlock) { const container = element.querySelector('.code-block-container') as HTMLElement;
console.error('CodeBlockHandler.setup: No code block element found');
return;
}
// Set initial content if needed - use textContent for code blocks if (!editor || !container) return;
if (block.content && !codeBlock.textContent) {
codeBlock.textContent = block.content; // Track if we're currently editing
} let isEditing = false;
// Focus handler
editor.addEventListener('focus', () => {
isEditing = true;
container.classList.add('editing');
handlers.onFocus();
});
// Blur handler
editor.addEventListener('blur', () => {
isEditing = false;
container.classList.remove('editing');
// Apply final highlighting on blur
this.applyHighlighting(element, block);
handlers.onBlur();
});
// Input handler // Input handler
codeBlock.addEventListener('input', (e) => { editor.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent); handlers.onInput(e as InputEvent);
// Track cursor position after input // Update line numbers
const pos = this.getCursorPosition(element); this.updateLineNumbers(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos; // Clear any pending highlight
clearTimeout(this.highlightTimer);
// Schedule highlighting (only if not actively editing)
if (!isEditing) {
this.highlightTimer = setTimeout(() => {
this.applyHighlighting(element, block);
}, 500);
} }
}); });
// Keydown handler // Keydown handler
codeBlock.addEventListener('keydown', (e) => { editor.addEventListener('keydown', (e) => {
// Track cursor position before keydown // Handle Tab
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Special handling for Tab key in code blocks
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
// Insert two spaces for tab
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(' '); const textNode = document.createTextNode(' ');
range.insertNode(textNode); range.insertNode(textNode);
range.setStartAfter(textNode); range.setStartAfter(textNode);
range.setEndAfter(textNode); range.setEndAfter(textNode);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input')); handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
} }
return; return;
} }
@ -83,54 +114,8 @@ export class CodeBlockHandler extends BaseBlockHandler {
handlers.onKeyDown(e); handlers.onKeyDown(e);
}); });
// Focus handler // Paste handler - plain text only
codeBlock.addEventListener('focus', () => { editor.addEventListener('paste', (e) => {
handlers.onFocus();
});
// Blur handler
codeBlock.addEventListener('blur', () => {
handlers.onBlur();
});
// Composition handlers for IME support
codeBlock.addEventListener('compositionstart', () => {
handlers.onCompositionStart();
});
codeBlock.addEventListener('compositionend', () => {
handlers.onCompositionEnd();
});
// Mouse up handler
codeBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
handlers.onMouseUp?.(e);
});
// Click handler with delayed cursor tracking
codeBlock.addEventListener('click', (e: MouseEvent) => {
setTimeout(() => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Keyup handler for cursor tracking
codeBlock.addEventListener('keyup', (e) => {
const pos = this.getCursorPosition(element);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Paste handler - handle as plain text
codeBlock.addEventListener('paste', (e) => {
e.preventDefault(); e.preventDefault();
const text = e.clipboardData?.getData('text/plain'); const text = e.clipboardData?.getData('text/plain');
if (text) { if (text) {
@ -144,257 +129,373 @@ export class CodeBlockHandler extends BaseBlockHandler {
range.setEndAfter(textNode); range.setEndAfter(textNode);
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
// Trigger input event
handlers.onInput(new InputEvent('input')); handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
} }
} }
}); });
// Composition handlers
editor.addEventListener('compositionstart', () => handlers.onCompositionStart());
editor.addEventListener('compositionend', () => handlers.onCompositionEnd());
// Initial syntax highlighting if content exists
if (block.content && !isEditing) {
requestAnimationFrame(() => {
this.applyHighlighting(element, block);
});
}
}
private updateLineNumbers(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLElement;
if (!editor || !lineNumbersContainer) return;
const content = editor.textContent || '';
const lines = content.split('\n');
const lineCount = lines.length || 1;
let lineNumbersHtml = '';
for (let i = 1; i <= lineCount; i++) {
lineNumbersHtml += `<div class="line-number">${i}</div>`;
}
lineNumbersContainer.innerHTML = lineNumbersHtml;
}
private applyHighlighting(element: HTMLElement, block: IBlock): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
// Store cursor position
const cursorPos = this.getCursorPosition(element);
// Get plain text content
const content = editor.textContent || '';
const language = block.metadata?.language || 'javascript';
// Apply highlighting
try {
const result = hlight.highlight(content, {
language: language,
ignoreIllegals: true
});
// Only update if we have valid highlighted content
if (result.value) {
editor.innerHTML = result.value;
// Restore cursor position if editor is focused
if (document.activeElement === editor && cursorPos !== null) {
requestAnimationFrame(() => {
WysiwygSelection.setCursorPosition(editor, cursorPos);
});
}
}
} catch (error) {
// If highlighting fails, keep plain text
console.warn('Syntax highlighting failed:', error);
}
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
getContent(element: HTMLElement): string {
const editor = element.querySelector('.code-editor') as HTMLElement;
return editor?.textContent || '';
}
setContent(element: HTMLElement, content: string): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.textContent = content;
this.updateLineNumbers(element);
// Apply highlighting if not focused
if (document.activeElement !== editor) {
const block: IBlock = {
id: editor.dataset.blockId || '',
type: 'code',
content: content,
metadata: {
language: element.querySelector('.code-block-container')?.getAttribute('data-language') || 'javascript'
}
};
this.applyHighlighting(element, block);
}
}
getCursorPosition(element: HTMLElement): number | null {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!editor.contains(range.startContainer)) return null;
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
return preCaretRange.toString().length;
}
setCursorToStart(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
WysiwygSelection.setCursorPosition(editor, 0);
}
}
setCursorToEnd(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (editor) {
const length = editor.textContent?.length || 0;
WysiwygSelection.setCursorPosition(editor, length);
}
}
focus(element: HTMLElement): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
editor?.focus();
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
if (!editor) return;
editor.focus();
requestAnimationFrame(() => {
if (position === 'start') {
this.setCursorToStart(element);
} else if (position === 'end') {
this.setCursorToEnd(element);
} else if (typeof position === 'number') {
WysiwygSelection.setCursorPosition(editor, position);
}
});
}
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
const position = this.getCursorPosition(element);
if (position === null) return null;
const content = this.getContent(element);
return {
before: content.substring(0, position),
after: content.substring(position)
};
} }
getStyles(): string { getStyles(): string {
return ` return `
/* Code block specific styles */ /* Code Block Container */
.code-block-container { .code-block-container {
position: relative; position: relative;
margin: 20px 0; margin: 16px 0;
background: ${cssManager.bdTheme('#f6f8fa', '#0d1117')};
border: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')};
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.block.code { .code-block-container.selected {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; border-color: ${cssManager.bdTheme('#0969da', '#58a6ff')};
font-size: 14px; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(9, 105, 218, 0.15)', 'rgba(88, 166, 255, 0.15)')};
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 20px;
padding-top: 32px;
border-radius: 6px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 0;
} }
.code-language { .code-block-container.editing {
position: absolute; border-color: ${cssManager.bdTheme('#0969da', '#58a6ff')};
top: 0; }
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')}; /* Header */
color: ${cssManager.bdTheme('#586069', '#8b949e')}; .code-header {
padding: 4px 12px; background: ${cssManager.bdTheme('#f6f8fa', '#161b22')};
border-bottom: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')};
padding: 8px 16px;
display: flex;
justify-content: flex-end;
align-items: center;
}
.language-label {
font-size: 12px; font-size: 12px;
border-radius: 0 6px 0 6px; color: ${cssManager.bdTheme('#57606a', '#8b949e')};
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: ${cssManager.bdTheme('#ffffff', '#0d1117')};
text-transform: lowercase; padding: 2px 8px;
z-index: 1; border-radius: 4px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Code Body */
.code-body {
display: flex;
position: relative;
}
/* Line Numbers */
.line-numbers {
flex-shrink: 0;
padding: 16px 0;
background: ${cssManager.bdTheme('#f6f8fa', '#010409')};
border-right: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')};
text-align: right;
user-select: none;
min-width: 50px;
}
.line-number {
padding: 0 12px;
color: ${cssManager.bdTheme('#57606a', '#484f58')};
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 14px;
line-height: 21px;
height: 21px;
}
/* Code Content */
.code-content {
flex: 1;
overflow-x: auto;
position: relative;
}
.code-pre {
margin: 0;
padding: 0;
background: transparent;
}
.code-editor {
display: block;
padding: 16px;
margin: 0;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 14px;
line-height: 21px;
color: ${cssManager.bdTheme('#24292f', '#c9d1d9')};
background: transparent;
border: none;
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
min-height: 84px;
overflow: visible;
}
/* Placeholder */
.code-editor:empty::before {
content: "// Type or paste code here...";
color: ${cssManager.bdTheme('#6a737d', '#484f58')};
pointer-events: none;
}
/* Syntax Highlighting Colors */
.code-editor .hljs-keyword {
color: ${cssManager.bdTheme('#d73a49', '#ff7b72')};
}
.code-editor .hljs-string {
color: ${cssManager.bdTheme('#032f62', '#a5d6ff')};
}
.code-editor .hljs-number {
color: ${cssManager.bdTheme('#005cc5', '#79c0ff')};
}
.code-editor .hljs-function {
color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')};
}
.code-editor .hljs-comment {
color: ${cssManager.bdTheme('#6a737d', '#8b949e')};
font-style: italic;
}
.code-editor .hljs-variable,
.code-editor .hljs-attr {
color: ${cssManager.bdTheme('#e36209', '#ffa657')};
}
.code-editor .hljs-class,
.code-editor .hljs-title {
color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')};
}
.code-editor .hljs-params {
color: ${cssManager.bdTheme('#24292f', '#c9d1d9')};
}
.code-editor .hljs-built_in {
color: ${cssManager.bdTheme('#005cc5', '#79c0ff')};
}
.code-editor .hljs-literal {
color: ${cssManager.bdTheme('#005cc5', '#79c0ff')};
}
.code-editor .hljs-meta {
color: ${cssManager.bdTheme('#735c0f', '#f2cc60')};
}
.code-editor .hljs-punctuation {
color: ${cssManager.bdTheme('#24292f', '#c9d1d9')};
}
.code-editor .hljs-tag {
color: ${cssManager.bdTheme('#22863a', '#7ee83f')};
}
.code-editor .hljs-attribute {
color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')};
}
.code-editor .hljs-selector-tag {
color: ${cssManager.bdTheme('#22863a', '#7ee83f')};
}
.code-editor .hljs-selector-class {
color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')};
}
.code-editor .hljs-selector-id {
color: ${cssManager.bdTheme('#005cc5', '#79c0ff')};
}
/* Selection */
.code-editor::selection,
.code-editor *::selection {
background: ${cssManager.bdTheme('rgba(9, 105, 218, 0.3)', 'rgba(88, 166, 255, 0.3)')};
}
/* Scrollbar styling */
.code-content::-webkit-scrollbar {
height: 8px;
}
.code-content::-webkit-scrollbar-track {
background: ${cssManager.bdTheme('#f6f8fa', '#010409')};
}
.code-content::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#d1d5da', '#30363d')};
border-radius: 4px;
}
.code-content::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#c8c8c8', '#484f58')};
} }
`; `;
} }
getPlaceholder(): string {
return '';
}
// Helper methods for code functionality
getCursorPosition(element: HTMLElement, context?: any): number | null {
// Get the actual code element
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
return null;
}
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(codeBlock);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
return position;
}
getContent(element: HTMLElement, context?: any): string {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return '';
// For code blocks, get textContent to avoid HTML formatting
const content = codeBlock.textContent || '';
return content;
}
setContent(element: HTMLElement, content: string, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Store if we have focus
const hadFocus = document.activeElement === codeBlock ||
element.shadowRoot?.activeElement === codeBlock;
// Use textContent for code blocks
codeBlock.textContent = content;
// Restore focus if we had it
if (hadFocus) {
codeBlock.focus();
}
}
setCursorToStart(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToStart(codeBlock);
}
}
setCursorToEnd(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (codeBlock) {
WysiwygBlocks.setCursorToEnd(codeBlock);
}
}
focus(element: HTMLElement, context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure the element is focusable
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
codeBlock.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== codeBlock && element.shadowRoot?.activeElement !== codeBlock) {
Promise.resolve().then(() => {
codeBlock.focus();
});
}
}
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) return;
// Ensure element is focusable first
if (!codeBlock.hasAttribute('contenteditable')) {
codeBlock.setAttribute('contenteditable', 'true');
}
// Focus the element
codeBlock.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart(element, context);
} else if (position === 'end') {
this.setCursorToEnd(element, context);
} else if (typeof position === 'number') {
// Use the selection utility to set cursor position
WysiwygSelection.setCursorPosition(codeBlock, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
setCursor();
}
});
}
}
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
if (!codeBlock) {
return null;
}
// Get shadow roots from context
const wysiwygBlock = context?.component;
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
const blockShadowRoot = context?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = codeBlock.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// Get cursor position
const cursorPos = this.getCursorPosition(element, context);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
return {
before: '',
after: codeBlock.textContent || ''
};
}
// For code blocks, split based on text content only
const fullText = codeBlock.textContent || '';
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
};
}
} }

View File

@ -48,12 +48,12 @@ export class DeesWysiwygBlock extends DeesElement {
private lastKnownCursorPosition: number = 0; private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = ''; private lastSelectedText: string = '';
private static handlerStylesInjected = false; private handlerStylesInjected = false;
private injectHandlerStyles(): void { private injectHandlerStyles(): void {
// Only inject once per component class // Only inject once per instance
if (DeesWysiwygBlock.handlerStylesInjected) return; if (this.handlerStylesInjected) return;
DeesWysiwygBlock.handlerStylesInjected = true; this.handlerStylesInjected = true;
// Get styles from all registered block handlers // Get styles from all registered block handlers
let styles = ''; let styles = '';
@ -131,20 +131,7 @@ export class DeesWysiwygBlock extends DeesElement {
margin: 16px 0; margin: 16px 0;
} }
.block.code { /* Code block styles moved to handler */
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
padding: 16px 20px;
padding-top: 32px;
border-radius: 6px;
white-space: pre-wrap;
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
line-height: 1.5;
overflow-x: auto;
margin: 20px 0;
}
.block.list { .block.list {
padding: 0; padding: 0;
@ -200,24 +187,7 @@ export class DeesWysiwygBlock extends DeesElement {
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
} }
.code-language { /* Code block container and language styles moved to handler */
position: absolute;
top: 0;
right: 0;
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
color: ${cssManager.bdTheme('#586069', '#8b949e')};
padding: 4px 12px;
font-size: 12px;
border-radius: 0 6px 0 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-transform: lowercase;
z-index: 1;
}
.code-block-container {
position: relative;
margin: 20px 0;
}
/* Selection styles */ /* Selection styles */
.block ::selection { .block ::selection {