Files
dees-catalog/ts_web/elements/wysiwyg/blocks/text/code.block.ts
2025-06-26 13:45:00 +00:00

669 lines
21 KiB
TypeScript

import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
import type { IBlock } from '../../wysiwyg.types.js';
import { cssManager } from '@design.estate/dees-element';
import { WysiwygSelection } from '../../wysiwyg.selection.js';
import hlight from 'highlight.js';
/**
* CodeBlockHandler with improved architecture
*
* Key features:
* 1. Simple DOM structure
* 2. Line number handling
* 3. Syntax highlighting only when not focused (grey text while editing)
* 4. Clean event handling
* 5. Copy button functionality
*/
export class CodeBlockHandler extends BaseBlockHandler {
type = 'code';
private highlightTimer: any = null;
render(block: IBlock, isSelected: boolean): string {
const language = block.metadata?.language || 'javascript';
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 `
<div class="code-block-container${isSelected ? ' selected' : ''}" data-language="${language}">
<div class="code-header">
<span class="language-label">${language}</span>
<button class="copy-button" title="Copy code">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</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"
data-block-id="${block.id}"
data-block-type="${block.type}"
spellcheck="false">${this.escapeHtml(content)}</code></pre>
</div>
</div>
</div>
`;
}
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
const editor = element.querySelector('.code-editor') as HTMLElement;
const container = element.querySelector('.code-block-container') as HTMLElement;
const copyButton = element.querySelector('.copy-button') as HTMLButtonElement;
if (!editor || !container) return;
// Setup copy button
if (copyButton) {
copyButton.addEventListener('click', async () => {
const content = editor.textContent || '';
try {
await navigator.clipboard.writeText(content);
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
// Reset after 2 seconds
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = content;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
// @ts-ignore - execCommand is deprecated but needed for fallback
document.execCommand('copy');
// Show feedback
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
const originalText = copyText.textContent;
copyText.textContent = 'Copied!';
copyButton.classList.add('copied');
setTimeout(() => {
copyText.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
}
});
}
// Track if we're currently editing
let isEditing = false;
// Focus handler
editor.addEventListener('focus', () => {
isEditing = true;
container.classList.add('editing');
// Remove all syntax highlighting when focused
const content = editor.textContent || '';
editor.textContent = content; // This removes all HTML formatting
// Restore cursor position after removing highlighting
requestAnimationFrame(() => {
const range = document.createRange();
const selection = window.getSelection();
if (editor.firstChild) {
range.setStart(editor.firstChild, 0);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
}
});
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
editor.addEventListener('input', (e) => {
handlers.onInput(e as InputEvent);
// Update line numbers
this.updateLineNumbers(element);
// Clear any pending highlight timer (no highlighting while editing)
clearTimeout(this.highlightTimer);
});
// Keydown handler
editor.addEventListener('keydown', (e) => {
// Handle Tab key for code blocks
if (e.key === 'Tab') {
e.preventDefault();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = document.createTextNode(' ');
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
handlers.onInput(new InputEvent('input'));
this.updateLineNumbers(element);
}
return;
}
// Check cursor position for navigation keys
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
const cursorPos = this.getCursorPosition(element);
const textLength = editor.textContent?.length || 0;
// For ArrowLeft at position 0 or ArrowRight at end, let parent handle navigation
if ((e.key === 'ArrowLeft' && cursorPos === 0) ||
(e.key === 'ArrowRight' && cursorPos === textLength)) {
// Pass to parent handler for inter-block navigation
handlers.onKeyDown(e);
return;
}
// For ArrowUp/Down, check if we're at first/last line
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const lines = (editor.textContent || '').split('\n');
const currentLine = this.getCurrentLineIndex(editor);
if ((e.key === 'ArrowUp' && currentLine === 0) ||
(e.key === 'ArrowDown' && currentLine === lines.length - 1)) {
// Let parent handle navigation to prev/next block
handlers.onKeyDown(e);
return;
}
}
}
// Pass other keys to parent handler
handlers.onKeyDown(e);
});
// Paste handler - plain text only
editor.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);
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 and not focused
if (block.content && document.activeElement !== editor) {
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 getCurrentLineIndex(editor: HTMLElement): number {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return 0;
const range = selection.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editor);
preCaretRange.setEnd(range.startContainer, range.startOffset);
const textBeforeCursor = preCaretRange.toString();
const linesBeforeCursor = textBeforeCursor.split('\n');
return linesBeforeCursor.length - 1; // 0-indexed
}
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 {
return `
/* Code Block Container - Minimalist shadcn style */
.code-block-container {
position: relative;
margin: 12px 0;
background: transparent;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
}
.code-block-container.selected {
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.code-block-container.editing {
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Header - Simplified */
.code-header {
background: transparent;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.language-label {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Copy Button - Minimal */
.copy-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
.copy-button:hover {
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.copy-button:active {
transform: scale(0.98);
}
.copy-button.copied {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.copy-icon {
flex-shrink: 0;
opacity: 0.7;
}
.copy-button:hover .copy-icon {
opacity: 1;
}
.copy-text {
min-width: 40px;
text-align: center;
}
/* Code Body */
.code-body {
display: flex;
position: relative;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
}
/* Line Numbers - Subtle */
.line-numbers {
flex-shrink: 0;
padding: 12px 0;
background: transparent;
text-align: right;
user-select: none;
min-width: 40px;
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
}
.line-number {
padding: 0 12px 0 8px;
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 13px;
line-height: 20px;
height: 20px;
}
/* Code Content */
.code-content {
flex: 1;
overflow-x: auto;
position: relative;
}
.code-pre {
margin: 0;
padding: 0;
background: transparent;
}
.code-editor {
display: block;
padding: 12px 16px;
margin: 0;
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 13px;
line-height: 20px;
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
background: transparent;
border: none;
outline: none;
white-space: pre-wrap;
word-wrap: break-word;
min-height: 60px;
overflow: visible;
}
/* Placeholder */
.code-editor:empty::before {
content: "// Type or paste code here...";
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
pointer-events: none;
}
/* When editing (focused), show grey text without highlighting */
.code-block-container.editing .code-editor {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')} !important;
}
.code-block-container.editing .code-editor * {
color: inherit !important;
}
/* Syntax Highlighting - Muted colors */
.code-editor .hljs-keyword {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
font-weight: 500;
}
.code-editor .hljs-string {
color: ${cssManager.bdTheme('#059669', '#10b981')};
}
.code-editor .hljs-number {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-function {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-comment {
color: ${cssManager.bdTheme('#6b7280', '#6b7280')};
font-style: italic;
}
.code-editor .hljs-variable,
.code-editor .hljs-attr {
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
}
.code-editor .hljs-class,
.code-editor .hljs-title {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
font-weight: 500;
}
.code-editor .hljs-params {
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
}
.code-editor .hljs-built_in {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-literal {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
.code-editor .hljs-meta {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.code-editor .hljs-punctuation {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.code-editor .hljs-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-attribute {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-tag {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.code-editor .hljs-selector-class {
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.code-editor .hljs-selector-id {
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
}
/* Selection */
.code-editor::selection,
.code-editor *::selection {
background: ${cssManager.bdTheme('rgba(99, 102, 241, 0.2)', 'rgba(99, 102, 241, 0.3)')};
}
/* Scrollbar styling - Minimal */
.code-content::-webkit-scrollbar {
height: 6px;
}
.code-content::-webkit-scrollbar-track {
background: transparent;
}
.code-content::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
border-radius: 3px;
}
.code-content::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
`;
}
}