519 lines
15 KiB
TypeScript
519 lines
15 KiB
TypeScript
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
|
import type { IBlock } from '../../wysiwyg.types.js';
|
|
import { cssManager } from '@design.estate/dees-element';
|
|
|
|
/**
|
|
* HTMLBlockHandler - Handles raw HTML content with preview/edit toggle
|
|
*
|
|
* Features:
|
|
* - Live HTML preview (sandboxed)
|
|
* - Edit/preview mode toggle
|
|
* - Syntax highlighting in edit mode
|
|
* - HTML validation hints
|
|
* - Auto-save on mode switch
|
|
*/
|
|
export class HtmlBlockHandler extends BaseBlockHandler {
|
|
type = 'html';
|
|
|
|
render(block: IBlock, isSelected: boolean): string {
|
|
const isEditMode = block.metadata?.isEditMode ?? true;
|
|
const content = block.content || '';
|
|
|
|
return `
|
|
<div class="html-block-container${isSelected ? ' selected' : ''}"
|
|
data-block-id="${block.id}"
|
|
data-edit-mode="${isEditMode}">
|
|
<div class="html-header">
|
|
<div class="html-icon"></></div>
|
|
<div class="html-title">HTML</div>
|
|
<button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
|
|
${isEditMode ? '👁️' : '✏️'}
|
|
</button>
|
|
</div>
|
|
<div class="html-content">
|
|
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderEditor(content: string): string {
|
|
return `
|
|
<textarea class="html-editor"
|
|
placeholder="Enter HTML content..."
|
|
spellcheck="false">${this.escapeHtml(content)}</textarea>
|
|
`;
|
|
}
|
|
|
|
private renderPreview(content: string): string {
|
|
return `
|
|
<div class="html-preview">
|
|
${content || '<div class="preview-empty">No content to preview</div>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
|
const container = element.querySelector('.html-block-container') as HTMLElement;
|
|
const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement;
|
|
|
|
if (!container || !toggleBtn) {
|
|
console.error('HtmlBlockHandler: Could not find required elements');
|
|
return;
|
|
}
|
|
|
|
// Initialize metadata
|
|
if (!block.metadata) block.metadata = {};
|
|
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
|
|
|
|
// Toggle mode button
|
|
toggleBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Save current content if in edit mode
|
|
if (block.metadata.isEditMode) {
|
|
const editor = container.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
block.content = editor.value;
|
|
}
|
|
}
|
|
|
|
// Toggle mode
|
|
block.metadata.isEditMode = !block.metadata.isEditMode;
|
|
|
|
// Request UI update
|
|
handlers.onRequestUpdate?.();
|
|
});
|
|
|
|
// Setup based on mode
|
|
if (block.metadata.isEditMode) {
|
|
this.setupEditor(element, block, handlers);
|
|
} else {
|
|
this.setupPreview(element, block, handlers);
|
|
}
|
|
}
|
|
|
|
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (!editor) return;
|
|
|
|
// Focus handling
|
|
editor.addEventListener('focus', () => handlers.onFocus());
|
|
editor.addEventListener('blur', () => handlers.onBlur());
|
|
|
|
// Content changes
|
|
editor.addEventListener('input', () => {
|
|
block.content = editor.value;
|
|
this.validateHtml(editor.value);
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
editor.addEventListener('keydown', (e) => {
|
|
// Tab handling for indentation
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
const value = editor.value;
|
|
|
|
if (e.shiftKey) {
|
|
// Unindent
|
|
const beforeCursor = value.substring(0, start);
|
|
const lastNewline = beforeCursor.lastIndexOf('\n');
|
|
const lineStart = lastNewline + 1;
|
|
const lineContent = value.substring(lineStart, start);
|
|
|
|
if (lineContent.startsWith(' ')) {
|
|
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
|
|
editor.selectionStart = editor.selectionEnd = start - 2;
|
|
}
|
|
} else {
|
|
// Indent
|
|
editor.value = value.substring(0, start) + ' ' + value.substring(end);
|
|
editor.selectionStart = editor.selectionEnd = start + 2;
|
|
}
|
|
|
|
block.content = editor.value;
|
|
return;
|
|
}
|
|
|
|
// Auto-close tags (Ctrl/Cmd + /)
|
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
|
e.preventDefault();
|
|
this.autoCloseTag(editor);
|
|
block.content = editor.value;
|
|
return;
|
|
}
|
|
|
|
// Pass other key events to handlers
|
|
handlers.onKeyDown(e);
|
|
});
|
|
|
|
// Auto-resize
|
|
this.autoResize(editor);
|
|
editor.addEventListener('input', () => this.autoResize(editor));
|
|
}
|
|
|
|
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
|
const container = element.querySelector('.html-block-container') as HTMLElement;
|
|
const preview = element.querySelector('.html-preview') as HTMLElement;
|
|
|
|
if (!container || !preview) return;
|
|
|
|
// Make preview focusable
|
|
preview.setAttribute('tabindex', '0');
|
|
|
|
// Focus handling
|
|
preview.addEventListener('focus', () => handlers.onFocus());
|
|
preview.addEventListener('blur', () => handlers.onBlur());
|
|
|
|
// Keyboard navigation
|
|
preview.addEventListener('keydown', (e) => {
|
|
// Switch to edit mode on Enter
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
block.metadata.isEditMode = true;
|
|
handlers.onRequestUpdate?.();
|
|
return;
|
|
}
|
|
|
|
handlers.onKeyDown(e);
|
|
});
|
|
|
|
// Sandbox styles and scripts in preview
|
|
this.sandboxContent(preview);
|
|
}
|
|
|
|
private autoCloseTag(editor: HTMLTextAreaElement): void {
|
|
const cursorPos = editor.selectionStart;
|
|
const text = editor.value;
|
|
|
|
// Find the opening tag
|
|
let tagStart = cursorPos;
|
|
while (tagStart > 0 && text[tagStart - 1] !== '<') {
|
|
tagStart--;
|
|
}
|
|
|
|
if (tagStart > 0) {
|
|
const tagContent = text.substring(tagStart, cursorPos);
|
|
const tagMatch = tagContent.match(/^(\w+)/);
|
|
|
|
if (tagMatch) {
|
|
const tagName = tagMatch[1];
|
|
const closingTag = `</${tagName}>`;
|
|
|
|
// Insert closing tag
|
|
editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos);
|
|
editor.selectionStart = editor.selectionEnd = cursorPos + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
private autoResize(editor: HTMLTextAreaElement): void {
|
|
editor.style.height = 'auto';
|
|
editor.style.height = editor.scrollHeight + 'px';
|
|
}
|
|
|
|
private validateHtml(html: string): boolean {
|
|
// Basic HTML validation
|
|
const openTags: string[] = [];
|
|
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
|
|
let match;
|
|
|
|
while ((match = tagRegex.exec(html)) !== null) {
|
|
const isClosing = match[0].startsWith('</');
|
|
const tagName = match[1].toLowerCase();
|
|
|
|
if (isClosing) {
|
|
if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) {
|
|
console.warn(`Mismatched closing tag: ${tagName}`);
|
|
return false;
|
|
}
|
|
openTags.pop();
|
|
} else if (!match[0].endsWith('/>')) {
|
|
// Not a self-closing tag
|
|
openTags.push(tagName);
|
|
}
|
|
}
|
|
|
|
if (openTags.length > 0) {
|
|
console.warn(`Unclosed tags: ${openTags.join(', ')}`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private sandboxContent(preview: HTMLElement): void {
|
|
// Remove any script tags
|
|
const scripts = preview.querySelectorAll('script');
|
|
scripts.forEach(script => script.remove());
|
|
|
|
// Remove event handlers
|
|
const allElements = preview.querySelectorAll('*');
|
|
allElements.forEach(el => {
|
|
// Remove all on* attributes
|
|
Array.from(el.attributes).forEach(attr => {
|
|
if (attr.name.startsWith('on')) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Prevent forms from submitting
|
|
const forms = preview.querySelectorAll('form');
|
|
forms.forEach(form => {
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
});
|
|
}
|
|
|
|
private escapeHtml(text: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
getContent(element: HTMLElement): string {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
return editor.value;
|
|
}
|
|
|
|
// If in preview mode, return the stored content
|
|
const container = element.querySelector('.html-block-container');
|
|
const blockId = container?.getAttribute('data-block-id');
|
|
// In real implementation, would need access to block data
|
|
return '';
|
|
}
|
|
|
|
setContent(element: HTMLElement, content: string): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
editor.value = content;
|
|
this.autoResize(editor);
|
|
}
|
|
}
|
|
|
|
getCursorPosition(element: HTMLElement): number | null {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
return editor ? editor.selectionStart : null;
|
|
}
|
|
|
|
setCursorToStart(element: HTMLElement): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
editor.selectionStart = editor.selectionEnd = 0;
|
|
editor.focus();
|
|
} else {
|
|
this.focus(element);
|
|
}
|
|
}
|
|
|
|
setCursorToEnd(element: HTMLElement): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
const length = editor.value.length;
|
|
editor.selectionStart = editor.selectionEnd = length;
|
|
editor.focus();
|
|
} else {
|
|
this.focus(element);
|
|
}
|
|
}
|
|
|
|
focus(element: HTMLElement): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
editor.focus();
|
|
} else {
|
|
const preview = element.querySelector('.html-preview') as HTMLElement;
|
|
preview?.focus();
|
|
}
|
|
}
|
|
|
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (editor) {
|
|
if (position === 'start') {
|
|
this.setCursorToStart(element);
|
|
} else if (position === 'end') {
|
|
this.setCursorToEnd(element);
|
|
} else if (typeof position === 'number') {
|
|
editor.selectionStart = editor.selectionEnd = position;
|
|
editor.focus();
|
|
}
|
|
} else {
|
|
this.focus(element);
|
|
}
|
|
}
|
|
|
|
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
|
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
if (!editor) return null;
|
|
|
|
const cursorPos = editor.selectionStart;
|
|
return {
|
|
before: editor.value.substring(0, cursorPos),
|
|
after: editor.value.substring(cursorPos)
|
|
};
|
|
}
|
|
|
|
getStyles(): string {
|
|
return `
|
|
/* HTML Block Container */
|
|
.html-block-container {
|
|
position: relative;
|
|
margin: 12px 0;
|
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
transition: all 0.15s ease;
|
|
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
|
}
|
|
|
|
.html-block-container.selected {
|
|
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
|
}
|
|
|
|
/* Header */
|
|
.html-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
|
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
|
}
|
|
|
|
.html-icon {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
opacity: 0.8;
|
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
|
}
|
|
|
|
.html-title {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
|
}
|
|
|
|
.html-toggle-mode {
|
|
padding: 4px 8px;
|
|
background: transparent;
|
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.html-toggle-mode:hover {
|
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
|
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
|
}
|
|
|
|
/* Content */
|
|
.html-content {
|
|
position: relative;
|
|
min-height: 120px;
|
|
}
|
|
|
|
/* Editor */
|
|
.html-editor {
|
|
width: 100%;
|
|
min-height: 120px;
|
|
padding: 12px;
|
|
background: transparent;
|
|
border: none;
|
|
outline: none;
|
|
resize: none;
|
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
|
overflow: hidden;
|
|
}
|
|
|
|
.html-editor::placeholder {
|
|
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
|
}
|
|
|
|
/* Preview */
|
|
.html-preview {
|
|
padding: 12px;
|
|
min-height: 96px;
|
|
outline: none;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
|
}
|
|
|
|
.preview-empty {
|
|
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Sandboxed HTML preview styles */
|
|
.html-preview * {
|
|
max-width: 100%;
|
|
}
|
|
|
|
.html-preview img {
|
|
height: auto;
|
|
}
|
|
|
|
.html-preview a {
|
|
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
|
text-decoration: none;
|
|
}
|
|
|
|
.html-preview a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.html-preview table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.html-preview th,
|
|
.html-preview td {
|
|
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
|
padding: 8px;
|
|
text-align: left;
|
|
}
|
|
|
|
.html-preview th {
|
|
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
|
font-weight: 600;
|
|
}
|
|
|
|
.html-preview pre {
|
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.html-preview code {
|
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.html-preview pre code {
|
|
background: transparent;
|
|
padding: 0;
|
|
}
|
|
`;
|
|
}
|
|
} |