Files
dees-catalog/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
2025-06-24 23:56:40 +00:00

2192 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
customElement,
property,
static as html,
DeesElement,
type TemplateResult,
cssManager,
css,
} from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js';
import { WysiwygBlocks } from './wysiwyg.blocks.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
import './wysiwyg.blockregistration.js';
declare global {
interface HTMLElementTagNameMap {
'dees-wysiwyg-block': DeesWysiwygBlock;
}
}
@customElement('dees-wysiwyg-block')
export class DeesWysiwygBlock extends DeesElement {
async disconnectedCallback() {
await super.disconnectedCallback();
// Clean up selection handler
if ((this as any)._selectionHandler) {
document.removeEventListener('selectionchange', (this as any)._selectionHandler);
}
}
@property({ type: Object })
public block: IBlock;
@property({ type: Boolean })
public isSelected: boolean = false;
@property({ type: Object })
public handlers: IBlockEventHandlers;
// Reference to the editable block element
private blockElement: HTMLDivElement | null = null;
// Track if we've initialized the content
private contentInitialized: boolean = false;
// Track cursor position
private lastKnownCursorPosition: number = 0;
private lastSelectedText: string = '';
private static handlerStylesInjected = false;
private injectHandlerStyles(): void {
// Only inject once per component class
if (DeesWysiwygBlock.handlerStylesInjected) return;
DeesWysiwygBlock.handlerStylesInjected = true;
// Get styles from all registered block handlers
let styles = '';
const blockTypes = BlockRegistry.getAllTypes();
for (const type of blockTypes) {
const handler = BlockRegistry.getHandler(type);
if (handler) {
styles += handler.getStyles();
}
}
if (styles) {
// Create and inject style element
const styleElement = document.createElement('style');
styleElement.textContent = styles;
this.shadowRoot?.appendChild(styleElement);
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.block {
padding: 4px 0;
min-height: 1.6em;
outline: none;
width: 100%;
word-wrap: break-word;
position: relative;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
}
.block:empty:not(:focus)::before {
content: attr(data-placeholder);
color: ${cssManager.bdTheme('#999', '#666')};
position: absolute;
pointer-events: none;
}
.block.heading-1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
margin: 24px 0 8px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
margin: 20px 0 6px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.heading-3 {
font-size: 20px;
font-weight: 600;
line-height: 1.4;
margin: 16px 0 4px 0;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block.quote {
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
padding-left: 20px;
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
font-style: italic;
line-height: 1.6;
margin: 16px 0;
}
.block.code {
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 {
padding: 0;
}
.block.list ul,
.block.list ol {
margin: 0;
padding-left: 24px;
}
.block.list li {
margin: 4px 0;
}
/* Formatting styles */
.block :is(b, strong) {
font-weight: 600;
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.block :is(i, em) {
font-style: italic;
}
.block u {
text-decoration: underline;
}
.block s {
text-decoration: line-through;
}
.block code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.9em;
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
padding: 2px 6px;
border-radius: 3px;
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
}
.block a {
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s ease;
cursor: pointer;
}
.block a:hover {
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.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 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 */
.block ::selection {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
color: inherit;
}
/* Paragraph specific styles */
.block.paragraph {
font-size: 16px;
line-height: 1.6;
font-weight: 400;
}
/* Strike through */
.block :is(s, strike) {
text-decoration: line-through;
opacity: 0.7;
}
/* List specific margin adjustments */
.block.list li {
margin-bottom: 8px;
line-height: 1.6;
}
.block.list li:last-child {
margin-bottom: 0;
}
/* Block margin adjustments based on type */
:host-context(.block-wrapper:first-child) .block {
margin-top: 0 !important;
}
:host-context(.block-wrapper:last-child) .block {
margin-bottom: 0;
}
/* Selected state */
.block.selected {
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
border-radius: 4px;
margin-left: -8px;
margin-right: -8px;
padding-left: 8px;
padding-right: 8px;
}
/* Image block styles */
.block.image {
min-height: 200px;
padding: 0;
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.block.image:focus {
outline: none;
}
.block.image.selected {
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
}
.image-upload-placeholder {
width: 100%;
height: 200px;
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.image-upload-placeholder:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#222222')};
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.image-upload-placeholder:active {
transform: scale(0.98);
}
.image-upload-placeholder.drag-over {
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
border-color: ${cssManager.bdTheme('#2196F3', '#64b5f6')};
}
.upload-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.7;
}
.upload-text {
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 8px;
}
.upload-hint {
font-size: 13px;
color: ${cssManager.bdTheme('#999', '#666')};
}
.image-container {
width: 100%;
position: relative;
}
.image-container img {
width: 100%;
height: auto;
display: block;
border-radius: 8px;
}
.image-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 8px;
font-size: 14px;
}
input[type="file"] {
display: none;
}
/* YouTube block styles */
.block.youtube {
padding: 0;
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
position: relative;
cursor: pointer;
transition: all 0.15s ease;
}
.block.youtube:focus {
outline: none;
}
.block.youtube.selected {
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
}
.youtube-container {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
}
.youtube-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.youtube-placeholder {
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
border-radius: 8px;
padding: 40px;
text-align: center;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
}
.placeholder-text {
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
margin-bottom: 16px;
}
.youtube-url-input {
width: 100%;
max-width: 400px;
padding: 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
font-size: 14px;
margin-bottom: 16px;
background: ${cssManager.bdTheme('#fff', '#222')};
color: ${cssManager.bdTheme('#000', '#fff')};
}
.youtube-embed-btn {
padding: 10px 24px;
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.youtube-embed-btn:hover {
background: ${cssManager.bdTheme('#0052a3', '#3d7dd9')};
}
/* Markdown block styles */
.block.markdown {
padding: 0;
margin: 16px 0;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.block.markdown:focus {
outline: none;
}
.block.markdown.selected {
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
}
.markdown-toolbar,
.html-toolbar {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
}
.markdown-label,
.html-label {
font-size: 12px;
text-transform: uppercase;
color: ${cssManager.bdTheme('#666', '#999')};
font-weight: 500;
}
.toggle-preview {
padding: 6px 12px;
background: ${cssManager.bdTheme('#fff', '#333')};
border: 1px solid ${cssManager.bdTheme('#ddd', '#555')};
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.toggle-preview:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#444')};
}
.markdown-content,
.html-content {
min-height: 200px;
}
.markdown-editor,
.html-editor {
width: 100%;
min-height: 200px;
padding: 16px;
border: none;
resize: vertical;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 14px;
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
color: ${cssManager.bdTheme('#000', '#fff')};
}
.markdown-preview,
.html-preview {
padding: 16px;
min-height: 200px;
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3 {
margin-top: 16px;
margin-bottom: 8px;
}
.markdown-preview h1:first-child,
.markdown-preview h2:first-child,
.markdown-preview h3:first-child {
margin-top: 0;
}
/* HTML block styles */
.block.html {
padding: 0;
margin: 16px 0;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.block.html:focus {
outline: none;
}
.block.html.selected {
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
}
/* Attachment block styles */
.block.attachment {
padding: 0;
margin: 16px 0;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s ease;
}
.block.attachment:focus {
outline: none;
}
.block.attachment.selected {
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
}
.block.attachment.drag-over {
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
}
.attachment-header {
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
}
.attachment-icon {
font-size: 24px;
}
.attachment-title {
font-size: 16px;
font-weight: 500;
}
.attachment-list {
padding: 16px;
min-height: 100px;
}
.attachment-placeholder {
text-align: center;
padding: 40px;
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.attachment-placeholder:hover {
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
.placeholder-hint {
font-size: 13px;
color: ${cssManager.bdTheme('#999', '#666')};
margin-top: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: ${cssManager.bdTheme('#f8f8f8', '#222')};
border-radius: 6px;
margin-bottom: 8px;
transition: background 0.2s ease;
}
.attachment-item:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#2a2a2a')};
}
.file-icon {
font-size: 24px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 12px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.remove-file {
width: 24px;
height: 24px;
border: none;
background: transparent;
color: ${cssManager.bdTheme('#999', '#666')};
font-size: 20px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
}
.attachment-item:hover .remove-file {
opacity: 1;
}
.remove-file:hover {
color: ${cssManager.bdTheme('#d32f2f', '#f44336')};
}
.add-more-files {
width: 100%;
padding: 10px;
background: transparent;
border: 1px dashed ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
color: ${cssManager.bdTheme('#666', '#999')};
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.add-more-files:hover {
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
`,
];
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
// If selection state changed, update the selected class without re-rendering
if (changedProperties.has('isSelected') && this.block) {
// Find the block element based on block type
let element: HTMLElement | null = null;
// Build the specific selector based on block type
const blockType = this.block.type;
const selector = `.block.${blockType}`;
element = this.shadowRoot?.querySelector(selector) as HTMLElement;
if (element) {
if (this.isSelected) {
element.classList.add('selected');
} else {
element.classList.remove('selected');
}
}
return false; // Don't re-render, just update the class
}
// Never update if only the block content changed
if (changedProperties.has('block') && this.block) {
const oldBlock = changedProperties.get('block');
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
// Only content or metadata changed, don't re-render
return false;
}
}
// Only update if the block type or id changes
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
}
public firstUpdated(): void {
// Mark that content has been initialized
this.contentInitialized = true;
// Inject handler styles if not already done
this.injectHandlerStyles();
// First, populate the container with the rendered content
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
// Check if we have a registered handler for this block type
if (this.block) {
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
if (blockElement) {
handler.setup(blockElement, this.block, this.handlers);
}
return; // Block handler takes care of all setup
}
}
// Handle special block types
if (this.block.type === 'image') {
this.setupImageBlock();
return; // Image blocks don't need the standard editable setup
} else if (this.block.type === 'youtube') {
this.setupYouTubeBlock();
return;
} else if (this.block.type === 'markdown') {
this.setupMarkdownBlock();
return;
} else if (this.block.type === 'html') {
this.setupHtmlBlock();
return;
} else if (this.block.type === 'attachment') {
this.setupAttachmentBlock();
return;
}
// Now find the actual editable block element
const editableBlock = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
// Ensure the block element maintains its content
if (editableBlock) {
editableBlock.setAttribute('data-block-id', this.block.id);
editableBlock.setAttribute('data-block-type', this.block.type);
// Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => {
this.handlers?.onInput?.(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
editableBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
this.handlers?.onKeyDown?.(e);
});
editableBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
editableBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
editableBlock.addEventListener('compositionstart', () => {
this.handlers?.onCompositionStart?.();
});
editableBlock.addEventListener('compositionend', () => {
this.handlers?.onCompositionEnd?.();
});
editableBlock.addEventListener('mouseup', (e) => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
// Selection will be handled by selectionchange event
this.handlers?.onMouseUp?.(e);
});
editableBlock.addEventListener('click', (e: MouseEvent) => {
// Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}, 0);
});
// Add selection change handler
const checkSelection = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const selectedText = selection.toString();
if (selectedText.length === 0) {
// Clear selection if no text
if (this.lastSelectedText) {
this.lastSelectedText = '';
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
return;
}
// Get fresh reference to the editable block
const currentEditableBlock = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!currentEditableBlock) return;
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Use getComposedRanges with shadow roots as per MDN docs
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Get selection info using our Shadow DOM-aware utility
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
// Check if selection is within this block
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.startContainer);
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
// Create range and get rect
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
const rect = range.getBoundingClientRect();
// Dispatch event
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: selectedText.trim(),
blockId: this.block.id,
range: range,
rect: rect,
hasSelection: true
},
bubbles: true,
composed: true
}));
}
} else if (this.lastSelectedText) {
// Clear selection if no longer in this block
this.lastSelectedText = '';
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
}
};
// Listen for selection changes
document.addEventListener('selectionchange', checkSelection);
// Store the handler for cleanup
(this as any)._selectionHandler = checkSelection;
// Add keyup handler for cursor position tracking
editableBlock.addEventListener('keyup', (e) => {
// Track cursor position
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
});
// Set initial content if needed
if (this.block.content) {
if (this.block.type === 'code') {
editableBlock.textContent = this.block.content;
} else if (this.block.type === 'list') {
editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
} else {
editableBlock.innerHTML = this.block.content;
}
}
}
// Store reference to the block element for quick access
this.blockElement = editableBlock;
}
render(): TemplateResult {
if (!this.block) return html``;
// Since we need dynamic content, we'll render an empty container
// and set the innerHTML in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
private renderBlockContent(): string {
if (!this.block) return '';
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler) {
return handler.render(this.block, this.isSelected);
}
if (this.block.type === 'code') {
const language = this.block.metadata?.language || 'plain text';
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div class="code-block-container">
<div class="code-language">${language}</div>
<div
class="block code${selectedClass}"
contenteditable="true"
data-block-type="${this.block.type}"
></div>
</div>
`;
}
if (this.block.type === 'image') {
const selectedClass = this.isSelected ? ' selected' : '';
const imageUrl = this.block.metadata?.url || '';
const isLoading = this.block.metadata?.loading || false;
return `
<div class="block image${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
${isLoading ? `
<div class="image-loading">Uploading image...</div>
` : ''}
${imageUrl ? `
<div class="image-container">
<img src="${imageUrl}" alt="${this.block.content || 'Uploaded image'}" />
</div>
` : `
<div class="image-upload-placeholder">
<div class="upload-icon">🖼️</div>
<div class="upload-text">Click to upload an image</div>
<div class="upload-hint">or drag and drop</div>
</div>
<input type="file" accept="image/*" style="display: none;" />
`}
</div>
`;
}
if (this.block.type === 'youtube') {
const selectedClass = this.isSelected ? ' selected' : '';
const videoId = this.block.metadata?.videoId || '';
const url = this.block.metadata?.url || '';
return `
<div class="block youtube${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
${videoId ? `
<div class="youtube-container">
<iframe
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
` : `
<div class="youtube-placeholder">
<div class="placeholder-icon">▶️</div>
<div class="placeholder-text">Enter YouTube URL</div>
<input type="url" class="youtube-url-input" placeholder="https://youtube.com/watch?v=..." value="${url || ''}" />
<button class="youtube-embed-btn">Embed Video</button>
</div>
`}
</div>
`;
}
if (this.block.type === 'markdown') {
const selectedClass = this.isSelected ? ' selected' : '';
const showPreview = this.block.metadata?.showPreview !== false;
return `
<div class="block markdown${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
<div class="markdown-toolbar">
<button class="toggle-preview" data-active="${showPreview}">
${showPreview ? 'Edit' : 'Preview'}
</button>
<span class="markdown-label">Markdown</span>
</div>
<div class="markdown-content">
${showPreview ? `
<div class="markdown-preview"></div>
` : `
<textarea class="markdown-editor" placeholder="Write markdown here...">${this.block.content || ''}</textarea>
`}
</div>
</div>
`;
}
if (this.block.type === 'html') {
const selectedClass = this.isSelected ? ' selected' : '';
const showPreview = this.block.metadata?.showPreview !== false;
return `
<div class="block html${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
<div class="html-toolbar">
<button class="toggle-preview" data-active="${showPreview}">
${showPreview ? 'Edit' : 'Preview'}
</button>
<span class="html-label">HTML</span>
</div>
<div class="html-content">
${showPreview ? `
<div class="html-preview"></div>
` : `
<textarea class="html-editor" placeholder="Write HTML here...">${this.block.content || ''}</textarea>
`}
</div>
</div>
`;
}
if (this.block.type === 'attachment') {
const selectedClass = this.isSelected ? ' selected' : '';
const files = this.block.metadata?.files || [];
return `
<div class="block attachment${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
<div class="attachment-header">
<div class="attachment-icon">📎</div>
<div class="attachment-title">File Attachments</div>
</div>
<div class="attachment-list">
${files.length > 0 ? files.map((file: any) => `
<div class="attachment-item">
<div class="file-icon">${this.getFileIcon(file.type)}</div>
<div class="file-info">
<div class="file-name">${file.name}</div>
<div class="file-size">${this.formatFileSize(file.size)}</div>
</div>
<button class="remove-file" data-file-id="${file.id}">×</button>
</div>
`).join('') : `
<div class="attachment-placeholder">
<div class="placeholder-text">Click to add files</div>
<div class="placeholder-hint">or drag and drop</div>
</div>
`}
</div>
<input type="file" multiple style="display: none;" />
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
</div>
`;
}
const placeholder = this.getPlaceholder();
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div
class="block ${this.block.type}${selectedClass}"
contenteditable="true"
data-placeholder="${placeholder}"
></div>
`;
}
private getPlaceholder(): string {
switch (this.block.type) {
case 'paragraph':
return "Type '/' for commands...";
case 'heading-1':
return 'Heading 1';
case 'heading-2':
return 'Heading 2';
case 'heading-3':
return 'Heading 3';
case 'quote':
return 'Quote';
case 'image':
return 'Click to upload an image';
default:
return '';
}
}
public focus(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focus) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focus(container, context);
}
// Handle non-editable blocks
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
if (this.block && nonEditableTypes.includes(this.block.type)) {
const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement;
if (blockElement) {
blockElement.focus();
}
return;
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure the element is focusable
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
editableElement.focus();
// If focus failed, try again after a microtask
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
Promise.resolve().then(() => {
editableElement.focus();
});
}
}
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.focusWithCursor) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.focusWithCursor(container, position, context);
}
// Non-editable blocks don't support cursor positioning
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
if (this.block && nonEditableTypes.includes(this.block.type)) {
this.focus();
return;
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Ensure element is focusable first
if (!editableElement.hasAttribute('contenteditable')) {
editableElement.setAttribute('contenteditable', 'true');
}
// Focus the element
editableElement.focus();
// Set cursor position after focus is established
const setCursor = () => {
if (position === 'start') {
this.setCursorToStart();
} else if (position === 'end') {
this.setCursorToEnd();
} else if (typeof position === 'number') {
// Use the new selection utility to set cursor position
WysiwygSelection.setCursorPosition(editableElement, position);
}
};
// Ensure cursor is set after focus
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
} else {
// Wait for focus to be established
Promise.resolve().then(() => {
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor();
}
});
}
}
/**
* Get cursor position in the editable element
*/
public getCursorPosition(element: HTMLElement): number | null {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getCursorPosition) {
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getCursorPosition(element, context);
}
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getCursorPosition: No selection found');
return null;
}
console.log('getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!element.contains(selectionInfo.startContainer)) {
console.log('getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: element.textContent,
elementTextLength: element.textContent?.length
});
return position;
}
public getContent(): string {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.getContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.getContent(container, context);
}
// Handle image blocks specially
if (this.block?.type === 'image') {
return this.block.content || ''; // Image blocks store alt text in content
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return '';
if (this.block.type === 'list') {
const listItems = editableElement.querySelectorAll('li');
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
} else if (this.block.type === 'code') {
return editableElement.textContent || '';
} else {
// For regular blocks, get the innerHTML which includes formatting tags
const content = editableElement.innerHTML || '';
console.log('Getting content from block:', content);
return content;
}
}
public setContent(content: string): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setContent(container, content, context);
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return;
// Store if we have focus
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
if (this.block.type === 'list') {
editableElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
} else if (this.block.type === 'code') {
editableElement.textContent = content;
} else {
editableElement.innerHTML = content;
}
// Restore focus if we had it
if (hadFocus) {
editableElement.focus();
}
}
public setCursorToStart(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToStart) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToStart(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
}
public setCursorToEnd(): void {
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
if (handler && handler.setCursorToEnd) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
const context = { shadowRoot: this.shadowRoot!, component: this };
return handler.setCursorToEnd(container, context);
}
// Always find the element fresh, don't rely on cached blockElement
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.setCursorToEnd(editableElement);
}
}
public focusListItem(): void {
if (this.block.type === 'list') {
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.focusListItem(editableElement);
}
}
}
/**
* Setup YouTube block functionality
*/
private setupYouTubeBlock(): void {
const youtubeBlock = this.shadowRoot?.querySelector('.block.youtube') as HTMLDivElement;
if (!youtubeBlock) return;
// Handle click to select
youtubeBlock.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('youtube-url-input') && !target.classList.contains('youtube-embed-btn')) {
e.stopPropagation();
youtubeBlock.focus();
this.handlers?.onFocus?.();
}
});
// Handle URL input and embed button
const urlInput = youtubeBlock.querySelector('.youtube-url-input') as HTMLInputElement;
const embedBtn = youtubeBlock.querySelector('.youtube-embed-btn') as HTMLButtonElement;
if (urlInput && embedBtn) {
const embedVideo = () => {
const url = urlInput.value.trim();
if (url) {
// Extract video ID from YouTube URL
const videoId = this.extractYouTubeVideoId(url);
if (videoId) {
this.block.metadata = { ...this.block.metadata, videoId, url };
this.block.content = url; // Store URL as content
// Re-render the block
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupYouTubeBlock(); // Re-setup event handlers
}
// Notify parent of change
this.handlers?.onInput?.(new InputEvent('input'));
} else {
alert('Invalid YouTube URL');
}
}
};
embedBtn.addEventListener('click', embedVideo);
urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
embedVideo();
}
});
}
// Handle focus/blur
youtubeBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
youtubeBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
// Handle keyboard events
youtubeBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
this.handlers?.onKeyDown?.(e);
} else {
this.handlers?.onKeyDown?.(e);
}
});
}
/**
* Setup Markdown block functionality
*/
private setupMarkdownBlock(): void {
const markdownBlock = this.shadowRoot?.querySelector('.block.markdown') as HTMLDivElement;
if (!markdownBlock) return;
// Handle click to select
markdownBlock.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('markdown-editor') && !target.classList.contains('toggle-preview')) {
e.stopPropagation();
markdownBlock.focus();
this.handlers?.onFocus?.();
}
});
// Handle preview toggle
const toggleBtn = markdownBlock.querySelector('.toggle-preview') as HTMLButtonElement;
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const showPreview = toggleBtn.dataset.active !== 'true';
this.block.metadata = { ...this.block.metadata, showPreview };
// Re-render
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupMarkdownBlock();
// If switching to preview, render markdown
if (showPreview) {
this.renderMarkdownPreview();
}
}
});
}
// Handle editor input
const editor = markdownBlock.querySelector('.markdown-editor') as HTMLTextAreaElement;
if (editor) {
editor.addEventListener('input', () => {
this.block.content = editor.value;
this.handlers?.onInput?.(new InputEvent('input'));
});
// Auto-resize textarea
const autoResize = () => {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
};
editor.addEventListener('input', autoResize);
autoResize();
}
// Render preview if needed
if (this.block.metadata?.showPreview) {
this.renderMarkdownPreview();
}
// Handle focus/blur
markdownBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
markdownBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
// Handle keyboard events
markdownBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
this.handlers?.onKeyDown?.(e);
} else {
this.handlers?.onKeyDown?.(e);
}
});
}
/**
* Setup HTML block functionality
*/
private setupHtmlBlock(): void {
const htmlBlock = this.shadowRoot?.querySelector('.block.html') as HTMLDivElement;
if (!htmlBlock) return;
// Handle click to select
htmlBlock.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('html-editor') && !target.classList.contains('toggle-preview')) {
e.stopPropagation();
htmlBlock.focus();
this.handlers?.onFocus?.();
}
});
// Handle preview toggle
const toggleBtn = htmlBlock.querySelector('.toggle-preview') as HTMLButtonElement;
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const showPreview = toggleBtn.dataset.active !== 'true';
this.block.metadata = { ...this.block.metadata, showPreview };
// Re-render
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupHtmlBlock();
// If switching to preview, render HTML
if (showPreview) {
this.renderHtmlPreview();
}
}
});
}
// Handle editor input
const editor = htmlBlock.querySelector('.html-editor') as HTMLTextAreaElement;
if (editor) {
editor.addEventListener('input', () => {
this.block.content = editor.value;
this.handlers?.onInput?.(new InputEvent('input'));
});
// Auto-resize textarea
const autoResize = () => {
editor.style.height = 'auto';
editor.style.height = editor.scrollHeight + 'px';
};
editor.addEventListener('input', autoResize);
autoResize();
}
// Render preview if needed
if (this.block.metadata?.showPreview) {
this.renderHtmlPreview();
}
// Handle focus/blur
htmlBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
htmlBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
// Handle keyboard events
htmlBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
this.handlers?.onKeyDown?.(e);
} else {
this.handlers?.onKeyDown?.(e);
}
});
}
/**
* Setup Attachment block functionality
*/
private setupAttachmentBlock(): void {
const attachmentBlock = this.shadowRoot?.querySelector('.block.attachment') as HTMLDivElement;
if (!attachmentBlock) return;
// Handle click to select
attachmentBlock.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.classList.contains('remove-file')) {
e.stopPropagation();
attachmentBlock.focus();
this.handlers?.onFocus?.();
}
});
// Handle file input
const fileInput = attachmentBlock.querySelector('input[type="file"]') as HTMLInputElement;
const placeholder = attachmentBlock.querySelector('.attachment-placeholder');
const addMoreBtn = attachmentBlock.querySelector('.add-more-files') as HTMLButtonElement;
const triggerFileInput = () => {
if (fileInput) fileInput.click();
};
if (placeholder) {
placeholder.addEventListener('click', triggerFileInput);
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', triggerFileInput);
}
if (fileInput) {
fileInput.addEventListener('change', async (e) => {
const files = Array.from((e.target as HTMLInputElement).files || []);
if (files.length > 0) {
await this.handleFileAttachments(files);
}
});
}
// Handle file removal
attachmentBlock.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('remove-file')) {
const fileId = target.dataset.fileId;
if (fileId) {
const files = this.block.metadata?.files || [];
this.block.metadata = {
...this.block.metadata,
files: files.filter((f: any) => f.id !== fileId)
};
// Re-render
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupAttachmentBlock();
}
this.handlers?.onInput?.(new InputEvent('input'));
}
}
});
// Handle drag and drop
attachmentBlock.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
attachmentBlock.classList.add('drag-over');
});
attachmentBlock.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
attachmentBlock.classList.remove('drag-over');
});
attachmentBlock.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
attachmentBlock.classList.remove('drag-over');
const files = Array.from(e.dataTransfer?.files || []);
if (files.length > 0) {
await this.handleFileAttachments(files);
}
});
// Handle focus/blur
attachmentBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
attachmentBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
// Handle keyboard events
attachmentBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
this.handlers?.onKeyDown?.(e);
} else {
this.handlers?.onKeyDown?.(e);
}
});
}
/**
* Extract YouTube video ID from URL
*/
private extractYouTubeVideoId(url: string): string | null {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/;
const match = url.match(regex);
return match ? match[1] : null;
}
/**
* Render Markdown preview
*/
private async renderMarkdownPreview(): Promise<void> {
const preview = this.shadowRoot?.querySelector('.markdown-preview') as HTMLDivElement;
if (!preview || !this.block.content) return;
// Simple markdown to HTML conversion (you might want to use a proper markdown parser)
let html = this.block.content
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*)\*/g, '<em>$1</em>')
.replace(/\[([^\]]*)\]\(([^\)]*)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>');
preview.innerHTML = html;
}
/**
* Render HTML preview
*/
private renderHtmlPreview(): void {
const preview = this.shadowRoot?.querySelector('.html-preview') as HTMLDivElement;
if (!preview || !this.block.content) return;
// Render HTML in a sandboxed way
preview.innerHTML = this.block.content;
}
/**
* Handle file attachments
*/
private async handleFileAttachments(files: File[]): Promise<void> {
const existingFiles = this.block.metadata?.files || [];
const newFiles: any[] = [];
for (const file of files) {
// Convert to base64 for storage (in production, upload to server)
const reader = new FileReader();
const base64 = await new Promise<string>((resolve) => {
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(file);
});
newFiles.push({
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
name: file.name,
size: file.size,
type: file.type,
data: base64
});
}
this.block.metadata = {
...this.block.metadata,
files: [...existingFiles, ...newFiles]
};
// Re-render
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupAttachmentBlock();
}
this.handlers?.onInput?.(new InputEvent('input'));
}
/**
* Get file icon based on mime type
*/
private getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎥';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('zip') || mimeType.includes('compressed')) return '🗄️';
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊';
if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📋';
if (mimeType.includes('text')) return '📃';
return '📁';
}
/**
* Format file size to human readable
*/
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Setup image block functionality
*/
private setupImageBlock(): void {
const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement;
if (!imageBlock) return;
// Note: tabindex is already set in the HTML
// Handle click to select the block
imageBlock.addEventListener('click', (e) => {
// Don't stop propagation for file input clicks
if ((e.target as HTMLElement).tagName !== 'INPUT') {
e.stopPropagation();
// Focus will trigger the selection
imageBlock.focus();
// Ensure focus handler is called immediately
this.handlers?.onFocus?.();
}
});
// Handle click on upload placeholder
const uploadPlaceholder = imageBlock.querySelector('.image-upload-placeholder');
const fileInput = imageBlock.querySelector('input[type="file"]') as HTMLInputElement;
if (uploadPlaceholder && fileInput) {
uploadPlaceholder.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
this.handleImageUpload(file);
}
});
// Handle drag and drop
imageBlock.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
uploadPlaceholder.classList.add('drag-over');
});
imageBlock.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
uploadPlaceholder.classList.remove('drag-over');
});
imageBlock.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
uploadPlaceholder.classList.remove('drag-over');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
this.handleImageUpload(file);
}
}
});
}
// Handle focus/blur for the image block
imageBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
imageBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
// Handle keyboard events
imageBlock.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
// Let the keyboard handler in the parent component handle the deletion
this.handlers?.onKeyDown?.(e);
} else {
// Handle navigation keys
this.handlers?.onKeyDown?.(e);
}
});
}
/**
* Handle image file upload
*/
private async handleImageUpload(file: File): Promise<void> {
// Check file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
alert('Image size must be less than 10MB');
return;
}
// Update block to show loading state
this.block.metadata = { ...this.block.metadata, loading: true };
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupImageBlock(); // Re-setup event handlers
}
try {
// Convert to base64 for now (in production, you'd upload to a server)
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
// Update block with image URL
this.block.metadata = {
...this.block.metadata,
url: base64,
loading: false,
fileName: file.name,
fileSize: file.size,
mimeType: file.type
};
// Set alt text as content
this.block.content = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
// Re-render
if (container) {
container.innerHTML = this.renderBlockContent();
}
// Notify parent component of the change
this.handlers?.onInput?.(new InputEvent('input'));
};
reader.onerror = () => {
alert('Failed to read image file');
this.block.metadata = { ...this.block.metadata, loading: false };
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupImageBlock();
}
};
reader.readAsDataURL(file);
} catch (error) {
console.error('Error uploading image:', error);
alert('Failed to upload image');
this.block.metadata = { ...this.block.metadata, loading: false };
if (container) {
container.innerHTML = this.renderBlockContent();
this.setupImageBlock();
}
}
}
/**
* Gets content split at cursor position
*/
public getSplitContent(): { before: string; after: string } | null {
console.log('getSplitContent: Starting...');
// Check if we have a registered handler for this block type
const handler = BlockRegistry.getHandler(this.block.type);
console.log('getSplitContent: Checking for handler', {
blockType: this.block.type,
hasHandler: !!handler,
hasSplitMethod: !!(handler && handler.getSplitContent)
});
if (handler && handler.getSplitContent) {
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
console.log('getSplitContent: Found container', {
container: !!container,
containerHTML: container?.innerHTML?.substring(0, 100)
});
const context = {
shadowRoot: this.shadowRoot!,
component: this
};
return handler.getSplitContent(container, context);
}
// Image blocks can't be split
if (this.block?.type === 'image') {
return null;
}
// Get the actual editable element first
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) {
console.log('getSplitContent: No editable element found');
return null;
}
console.log('getSplitContent: Element info:', {
blockType: this.block.type,
innerHTML: editableElement.innerHTML,
textContent: editableElement.textContent,
textLength: editableElement.textContent?.length
});
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('getSplitContent: Splitting with last known position:', {
pos,
fullTextLength: fullText.length,
before: fullText.substring(0, pos),
after: fullText.substring(pos)
});
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
console.log('getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// For code blocks, use simple text splitting
if (this.block.type === 'code') {
const cursorPos = this.getCursorPosition(editableElement) || 0;
const fullText = editableElement.textContent || '';
console.log('getSplitContent: Code block split:', {
cursorPos,
fullTextLength: fullText.length,
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
});
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
};
}
// For HTML content, get cursor position first
const cursorPos = this.getCursorPosition(editableElement);
console.log('getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: editableElement.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange();
const afterRange = document.createRange();
// Before range: from start of element to cursor
beforeRange.setStart(editableElement, 0);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(editableElement, editableElement.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings
const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = '';
tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML;
console.log('getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml,
afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
});
return {
before: beforeHtml,
after: afterHtml
};
}
}