update
This commit is contained in:
519
ts_web/elements/wysiwyg/blocks/content/html.block.ts
Normal file
519
ts_web/elements/wysiwyg/blocks/content/html.block.ts
Normal file
@ -0,0 +1,519 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user