562 lines
17 KiB
TypeScript
562 lines
17 KiB
TypeScript
![]() |
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||
|
import { cssManager } from '@design.estate/dees-element';
|
||
|
|
||
|
/**
|
||
|
* MarkdownBlockHandler - Handles markdown content with preview/edit toggle
|
||
|
*
|
||
|
* Features:
|
||
|
* - Live markdown preview
|
||
|
* - Edit/preview mode toggle
|
||
|
* - Syntax highlighting in edit mode
|
||
|
* - Common markdown shortcuts
|
||
|
* - Auto-save on mode switch
|
||
|
*/
|
||
|
export class MarkdownBlockHandler extends BaseBlockHandler {
|
||
|
type = 'markdown';
|
||
|
|
||
|
render(block: IBlock, isSelected: boolean): string {
|
||
|
const isEditMode = block.metadata?.isEditMode ?? true;
|
||
|
const content = block.content || '';
|
||
|
|
||
|
return `
|
||
|
<div class="markdown-block-container${isSelected ? ' selected' : ''}"
|
||
|
data-block-id="${block.id}"
|
||
|
data-edit-mode="${isEditMode}">
|
||
|
<div class="markdown-header">
|
||
|
<div class="markdown-icon">M↓</div>
|
||
|
<div class="markdown-title">Markdown</div>
|
||
|
<button class="markdown-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
|
||
|
${isEditMode ? '👁️' : '✏️'}
|
||
|
</button>
|
||
|
</div>
|
||
|
<div class="markdown-content">
|
||
|
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
|
||
|
</div>
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
private renderEditor(content: string): string {
|
||
|
return `
|
||
|
<textarea class="markdown-editor"
|
||
|
placeholder="Enter markdown content..."
|
||
|
spellcheck="false">${this.escapeHtml(content)}</textarea>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
private renderPreview(content: string): string {
|
||
|
const html = this.parseMarkdown(content);
|
||
|
return `
|
||
|
<div class="markdown-preview">
|
||
|
${html || '<div class="preview-empty">No content to preview</div>'}
|
||
|
</div>
|
||
|
`;
|
||
|
}
|
||
|
|
||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||
|
const container = element.querySelector('.markdown-block-container') as HTMLElement;
|
||
|
const toggleBtn = element.querySelector('.markdown-toggle-mode') as HTMLButtonElement;
|
||
|
|
||
|
if (!container || !toggleBtn) {
|
||
|
console.error('MarkdownBlockHandler: 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('.markdown-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('.markdown-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;
|
||
|
});
|
||
|
|
||
|
// 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;
|
||
|
}
|
||
|
|
||
|
// Bold shortcut (Ctrl/Cmd + B)
|
||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||
|
e.preventDefault();
|
||
|
this.wrapSelection(editor, '**', '**');
|
||
|
block.content = editor.value;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Italic shortcut (Ctrl/Cmd + I)
|
||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
||
|
e.preventDefault();
|
||
|
this.wrapSelection(editor, '_', '_');
|
||
|
block.content = editor.value;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Link shortcut (Ctrl/Cmd + K)
|
||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||
|
e.preventDefault();
|
||
|
this.insertLink(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('.markdown-block-container') as HTMLElement;
|
||
|
const preview = element.querySelector('.markdown-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);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
private wrapSelection(editor: HTMLTextAreaElement, before: string, after: string): void {
|
||
|
const start = editor.selectionStart;
|
||
|
const end = editor.selectionEnd;
|
||
|
const selectedText = editor.value.substring(start, end);
|
||
|
const replacement = before + (selectedText || 'text') + after;
|
||
|
|
||
|
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
|
||
|
|
||
|
if (selectedText) {
|
||
|
editor.selectionStart = start;
|
||
|
editor.selectionEnd = start + replacement.length;
|
||
|
} else {
|
||
|
editor.selectionStart = start + before.length;
|
||
|
editor.selectionEnd = start + before.length + 4; // 'text'.length
|
||
|
}
|
||
|
|
||
|
editor.focus();
|
||
|
}
|
||
|
|
||
|
private insertLink(editor: HTMLTextAreaElement): void {
|
||
|
const start = editor.selectionStart;
|
||
|
const end = editor.selectionEnd;
|
||
|
const selectedText = editor.value.substring(start, end);
|
||
|
const linkText = selectedText || 'link text';
|
||
|
const replacement = `[${linkText}](url)`;
|
||
|
|
||
|
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
|
||
|
|
||
|
// Select the URL part
|
||
|
editor.selectionStart = start + linkText.length + 3; // '[linktext]('.length
|
||
|
editor.selectionEnd = start + linkText.length + 6; // '[linktext](url'.length
|
||
|
|
||
|
editor.focus();
|
||
|
}
|
||
|
|
||
|
private autoResize(editor: HTMLTextAreaElement): void {
|
||
|
editor.style.height = 'auto';
|
||
|
editor.style.height = editor.scrollHeight + 'px';
|
||
|
}
|
||
|
|
||
|
private parseMarkdown(markdown: string): string {
|
||
|
// Basic markdown parsing - in production, use a proper markdown parser
|
||
|
let html = this.escapeHtml(markdown);
|
||
|
|
||
|
// Headers
|
||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||
|
|
||
|
// Bold
|
||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||
|
|
||
|
// Italic
|
||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||
|
|
||
|
// Code blocks
|
||
|
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
|
||
|
|
||
|
// Inline code
|
||
|
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
|
||
|
|
||
|
// Links
|
||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||
|
|
||
|
// Lists
|
||
|
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
|
||
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||
|
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||
|
|
||
|
// Wrap consecutive list items
|
||
|
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => {
|
||
|
return '<ul>' + match + '</ul>';
|
||
|
});
|
||
|
|
||
|
// Paragraphs
|
||
|
html = html.replace(/\n\n/g, '</p><p>');
|
||
|
html = '<p>' + html + '</p>';
|
||
|
|
||
|
// Clean up empty paragraphs
|
||
|
html = html.replace(/<p><\/p>/g, '');
|
||
|
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
|
||
|
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
|
||
|
html = html.replace(/<p>(<ul>)/g, '$1');
|
||
|
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||
|
html = html.replace(/<p>(<pre>)/g, '$1');
|
||
|
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
|
||
|
|
||
|
return html;
|
||
|
}
|
||
|
|
||
|
private escapeHtml(text: string): string {
|
||
|
const div = document.createElement('div');
|
||
|
div.textContent = text;
|
||
|
return div.innerHTML;
|
||
|
}
|
||
|
|
||
|
getContent(element: HTMLElement): string {
|
||
|
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||
|
if (editor) {
|
||
|
return editor.value;
|
||
|
}
|
||
|
|
||
|
// If in preview mode, return the stored content
|
||
|
const container = element.querySelector('.markdown-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('.markdown-editor') as HTMLTextAreaElement;
|
||
|
if (editor) {
|
||
|
editor.value = content;
|
||
|
this.autoResize(editor);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
getCursorPosition(element: HTMLElement): number | null {
|
||
|
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||
|
return editor ? editor.selectionStart : null;
|
||
|
}
|
||
|
|
||
|
setCursorToStart(element: HTMLElement): void {
|
||
|
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||
|
if (editor) {
|
||
|
editor.selectionStart = editor.selectionEnd = 0;
|
||
|
editor.focus();
|
||
|
} else {
|
||
|
this.focus(element);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
setCursorToEnd(element: HTMLElement): void {
|
||
|
const editor = element.querySelector('.markdown-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('.markdown-editor') as HTMLTextAreaElement;
|
||
|
if (editor) {
|
||
|
editor.focus();
|
||
|
} else {
|
||
|
const preview = element.querySelector('.markdown-preview') as HTMLElement;
|
||
|
preview?.focus();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||
|
const editor = element.querySelector('.markdown-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('.markdown-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 `
|
||
|
/* Markdown Block Container */
|
||
|
.markdown-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')};
|
||
|
}
|
||
|
|
||
|
.markdown-block-container.selected {
|
||
|
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||
|
}
|
||
|
|
||
|
/* Header */
|
||
|
.markdown-header {
|
||
|
display: flex;
|
||
|
align-items: center;
|
||
|
gap: 8px;
|
||
|
padding: 8px 12px;
|
||
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
|
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||
|
}
|
||
|
|
||
|
.markdown-icon {
|
||
|
font-size: 14px;
|
||
|
font-weight: 600;
|
||
|
opacity: 0.8;
|
||
|
}
|
||
|
|
||
|
.markdown-title {
|
||
|
flex: 1;
|
||
|
font-size: 13px;
|
||
|
font-weight: 500;
|
||
|
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||
|
}
|
||
|
|
||
|
.markdown-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;
|
||
|
}
|
||
|
|
||
|
.markdown-toggle-mode:hover {
|
||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||
|
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||
|
}
|
||
|
|
||
|
/* Content */
|
||
|
.markdown-content {
|
||
|
position: relative;
|
||
|
min-height: 120px;
|
||
|
}
|
||
|
|
||
|
/* Editor */
|
||
|
.markdown-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;
|
||
|
}
|
||
|
|
||
|
.markdown-editor::placeholder {
|
||
|
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||
|
}
|
||
|
|
||
|
/* Preview */
|
||
|
.markdown-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;
|
||
|
}
|
||
|
|
||
|
/* Markdown preview styles */
|
||
|
.markdown-preview h1 {
|
||
|
font-size: 24px;
|
||
|
font-weight: 600;
|
||
|
margin: 16px 0 8px 0;
|
||
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||
|
}
|
||
|
|
||
|
.markdown-preview h2 {
|
||
|
font-size: 20px;
|
||
|
font-weight: 600;
|
||
|
margin: 14px 0 6px 0;
|
||
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||
|
}
|
||
|
|
||
|
.markdown-preview h3 {
|
||
|
font-size: 18px;
|
||
|
font-weight: 600;
|
||
|
margin: 12px 0 4px 0;
|
||
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||
|
}
|
||
|
|
||
|
.markdown-preview p {
|
||
|
margin: 8px 0;
|
||
|
}
|
||
|
|
||
|
.markdown-preview ul,
|
||
|
.markdown-preview ol {
|
||
|
margin: 8px 0;
|
||
|
padding-left: 24px;
|
||
|
}
|
||
|
|
||
|
.markdown-preview li {
|
||
|
margin: 4px 0;
|
||
|
}
|
||
|
|
||
|
.markdown-preview code {
|
||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||
|
padding: 2px 4px;
|
||
|
border-radius: 3px;
|
||
|
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||
|
font-size: 0.9em;
|
||
|
}
|
||
|
|
||
|
.markdown-preview pre {
|
||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||
|
padding: 12px;
|
||
|
border-radius: 4px;
|
||
|
overflow-x: auto;
|
||
|
margin: 8px 0;
|
||
|
}
|
||
|
|
||
|
.markdown-preview pre code {
|
||
|
background: transparent;
|
||
|
padding: 0;
|
||
|
}
|
||
|
|
||
|
.markdown-preview strong {
|
||
|
font-weight: 600;
|
||
|
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||
|
}
|
||
|
|
||
|
.markdown-preview em {
|
||
|
font-style: italic;
|
||
|
}
|
||
|
|
||
|
.markdown-preview a {
|
||
|
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||
|
text-decoration: none;
|
||
|
}
|
||
|
|
||
|
.markdown-preview a:hover {
|
||
|
text-decoration: underline;
|
||
|
}
|
||
|
|
||
|
.markdown-preview blockquote {
|
||
|
border-left: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||
|
padding-left: 12px;
|
||
|
margin: 8px 0;
|
||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||
|
}
|
||
|
`;
|
||
|
}
|
||
|
}
|