Files
dees-catalog/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Juergen Kunz 89a4a15e78 update
2025-06-24 18:43:51 +00:00

1260 lines
39 KiB
TypeScript

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';
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: {
onInput: (e: InputEvent) => void;
onKeyDown: (e: KeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onMouseUp?: (e: MouseEvent) => void;
};
// 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 = '';
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;
}
.block.divider {
padding: 8px 0;
margin: 16px 0;
cursor: pointer;
position: relative;
border-radius: 4px;
transition: all 0.15s ease;
}
.block.divider:focus {
outline: none;
}
.block.divider.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)')};
}
.block.divider hr {
border: none;
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
margin: 0;
pointer-events: none;
}
/* 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;
}
`,
];
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
// If selection state changed, we need to update for non-editable blocks
if (changedProperties.has('isSelected') && (this.block?.type === 'divider' || this.block?.type === 'image')) {
// For non-editable blocks, we need to update the selected class
const element = this.shadowRoot?.querySelector('.block') 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 === 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;
// 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();
}
// 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 === 'divider') {
this.setupDividerBlock();
return; // Divider blocks don't need the standard editable setup
}
// 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;
console.log('Cursor position after mouseup:', 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;
console.log('Cursor position after click:', 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 = currentEditableBlock.contains(selectionInfo.startContainer);
const endInBlock = currentEditableBlock.contains(selectionInfo.endContainer);
if (startInBlock || endInBlock) {
if (selectedText !== this.lastSelectedText) {
this.lastSelectedText = selectedText;
console.log('✅ Selection detected in block using getComposedRanges:', 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 '';
if (this.block.type === 'divider') {
const selectedClass = this.isSelected ? ' selected' : '';
return `
<div class="block divider${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
<hr>
</div>
`;
}
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>
`;
}
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 {
// Handle non-editable blocks
if (this.block?.type === 'image') {
const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement;
if (imageBlock) {
imageBlock.focus();
}
return;
} else if (this.block?.type === 'divider') {
const dividerBlock = this.shadowRoot?.querySelector('.block.divider') as HTMLDivElement;
if (dividerBlock) {
dividerBlock.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 {
// Non-editable blocks don't support cursor positioning
if (this.block?.type === 'image' || this.block?.type === 'divider') {
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 {
// 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 {
// 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 {
// 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 {
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
}
public setCursorToEnd(): void {
const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
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 divider block functionality
*/
private setupDividerBlock(): void {
const dividerBlock = this.shadowRoot?.querySelector('.block.divider') as HTMLDivElement;
if (!dividerBlock) return;
// Handle click to select
dividerBlock.addEventListener('click', (e) => {
e.stopPropagation();
// Focus will trigger the selection
dividerBlock.focus();
});
// Handle focus/blur
dividerBlock.addEventListener('focus', () => {
this.handlers?.onFocus?.();
});
dividerBlock.addEventListener('blur', () => {
this.handlers?.onBlur?.();
});
// Handle keyboard events
dividerBlock.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);
}
});
}
/**
* 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();
}
});
// 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...');
// 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 (!editableElement.contains(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
};
}
}