feat(editor): Add wysiwyg editor

This commit is contained in:
Juergen Kunz
2025-06-23 18:02:40 +00:00
parent 58af08cb0d
commit a1079cbbdd
4 changed files with 285 additions and 18 deletions

View File

@ -60,6 +60,15 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
@state()
private slashMenuSelectedIndex: number = 0;
@state()
private draggedBlockId: string | null = null;
@state()
private dragOverBlockId: string | null = null;
@state()
private dragOverPosition: 'before' | 'after' | null = null;
private editorContentRef: HTMLDivElement;
private isComposing: boolean = false;
private saveTimeout: any = null;
@ -73,26 +82,35 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
async firstUpdated() {
this.updateValue();
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
// Set initial content for blocks
this.setBlockContents();
// Set initial content for blocks after a brief delay to ensure DOM is ready
await this.updateComplete;
setTimeout(() => {
this.setBlockContents();
}, 50);
}
updated(changedProperties: Map<string, any>) {
// When blocks change (e.g., from setValue), update DOM content
if (changedProperties.has('blocks')) {
this.setBlockContents();
// Wait for render to complete
setTimeout(() => {
this.setBlockContents();
}, 50);
}
}
private setBlockContents() {
// Only set content for blocks that aren't being edited
this.blocks.forEach(block => {
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') {
if (block.type === 'list') {
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
} else {
blockElement.textContent = block.content;
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`);
if (wrapperElement) {
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') {
if (block.type === 'list') {
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
} else {
blockElement.textContent = block.content;
}
}
}
});
@ -107,7 +125,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
></dees-label>
<div class="wysiwyg-container">
<div
class="editor-content"
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
@click="${this.handleEditorClick}"
>
${this.blocks.map(block => this.renderBlock(block))}
@ -119,15 +137,35 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
private renderBlock(block: IBlock): TemplateResult {
const isSelected = this.selectedBlockId === block.id;
const isDragging = this.draggedBlockId === block.id;
const isDragOver = this.dragOverBlockId === block.id;
return WysiwygBlocks.renderBlock(block, isSelected, {
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block),
onFocus: () => this.handleBlockFocus(block),
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
});
return html`
<div
class="block-wrapper ${isDragging ? 'dragging' : ''} ${isDragOver && this.dragOverPosition === 'before' ? 'drag-over-before' : ''} ${isDragOver && this.dragOverPosition === 'after' ? 'drag-over-after' : ''}"
data-block-id="${block.id}"
@dragover="${(e: DragEvent) => this.handleDragOver(e, block)}"
@drop="${(e: DragEvent) => this.handleDrop(e, block)}"
@dragleave="${() => this.handleDragLeave(block)}"
>
${block.type !== 'divider' ? html`
<div
class="drag-handle"
draggable="true"
@dragstart="${(e: DragEvent) => this.handleDragStart(e, block)}"
@dragend="${() => this.handleDragEnd()}"
></div>
` : ''}
${WysiwygBlocks.renderBlock(block, isSelected, {
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block),
onFocus: () => this.handleBlockFocus(block),
onBlur: () => this.handleBlockBlur(block),
onCompositionStart: () => this.isComposing = true,
onCompositionEnd: () => this.isComposing = false,
})}
</div>
`;
}
private getFilteredMenuItems(): ISlashMenuItem[] {
@ -611,4 +649,84 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}
this.importBlocks(state.blocks);
}
// Drag and Drop Handlers
private handleDragStart(e: DragEvent, block: IBlock): void {
if (!e.dataTransfer) return;
this.draggedBlockId = block.id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', block.id);
// Add a slight delay to show the dragging state
setTimeout(() => {
this.requestUpdate();
}, 10);
}
private handleDragEnd(): void {
this.draggedBlockId = null;
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.requestUpdate();
}
private handleDragOver(e: DragEvent, block: IBlock): void {
e.preventDefault();
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
e.dataTransfer.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
this.dragOverBlockId = block.id;
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
this.requestUpdate();
}
private handleDragLeave(block: IBlock): void {
if (this.dragOverBlockId === block.id) {
this.dragOverBlockId = null;
this.dragOverPosition = null;
this.requestUpdate();
}
}
private handleDrop(e: DragEvent, targetBlock: IBlock): void {
e.preventDefault();
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
const draggedIndex = this.blocks.findIndex(b => b.id === this.draggedBlockId);
const targetIndex = this.blocks.findIndex(b => b.id === targetBlock.id);
if (draggedIndex === -1 || targetIndex === -1) return;
// Remove the dragged block
const [draggedBlock] = this.blocks.splice(draggedIndex, 1);
// Calculate the new index
let newIndex = targetIndex;
if (this.dragOverPosition === 'after') {
newIndex = draggedIndex < targetIndex ? targetIndex : targetIndex + 1;
} else {
newIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex;
}
// Insert at new position
this.blocks.splice(newIndex, 0, draggedBlock);
// Update state
this.updateValue();
this.handleDragEnd();
// Focus the moved block
setTimeout(() => {
const movedBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${draggedBlock.id}"] .block`) as HTMLDivElement;
if (movedBlockElement && draggedBlock.type !== 'divider') {
movedBlockElement.focus();
}
}, 100);
}
}

View File

@ -61,6 +61,7 @@ export class WysiwygBlocks {
@blur="${handlers.onBlur}"
@compositionstart="${handlers.onCompositionStart}"
@compositionend="${handlers.onCompositionEnd}"
.textContent="${block.content}"
></div>
`;
}

View File

@ -282,4 +282,74 @@ export const wysiwygStyles = css`
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
}
/* Drag and Drop Styles */
.block-wrapper {
position: relative;
transition: all 0.2s ease;
}
.drag-handle {
position: absolute;
left: -30px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
cursor: grab;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('#999', '#666')};
}
.drag-handle::before {
content: "⋮⋮";
font-size: 12px;
letter-spacing: -2px;
}
.block-wrapper:hover .drag-handle {
opacity: 1;
}
.drag-handle:hover {
color: ${cssManager.bdTheme('#666', '#999')};
}
.drag-handle:active {
cursor: grabbing;
}
.block-wrapper.dragging {
opacity: 0.5;
}
.block-wrapper.drag-over-before::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 3px;
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 2px;
}
.block-wrapper.drag-over-after::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 3px;
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 2px;
}
.editor-content.dragging * {
user-select: none;
}
`;