feat(editor): Add wysiwyg editor
This commit is contained in:
@@ -6,6 +6,7 @@ export const demoFunc = () => html`
|
|||||||
// Get editor instances
|
// Get editor instances
|
||||||
const programmaticEditor = elementArg.querySelector('#programmatic-demo') as any;
|
const programmaticEditor = elementArg.querySelector('#programmatic-demo') as any;
|
||||||
const articleEditor = elementArg.querySelector('#article-editor') as any;
|
const articleEditor = elementArg.querySelector('#article-editor') as any;
|
||||||
|
const dragDemoEditor = elementArg.querySelector('#drag-demo') as any;
|
||||||
|
|
||||||
// Programmatically set content for the article editor after render
|
// Programmatically set content for the article editor after render
|
||||||
if (articleEditor) {
|
if (articleEditor) {
|
||||||
@@ -85,6 +86,57 @@ export const demoFunc = () => html`
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize drag demo editor with sample content
|
||||||
|
if (dragDemoEditor) {
|
||||||
|
const dragDemoBlocks = [
|
||||||
|
{
|
||||||
|
id: 'drag-title-' + Date.now(),
|
||||||
|
type: 'heading-1' as const,
|
||||||
|
content: 'Drag & Drop Demo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-intro-' + Date.now(),
|
||||||
|
type: 'paragraph' as const,
|
||||||
|
content: 'This editor demonstrates drag and drop functionality. Try dragging these blocks around!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-heading-' + Date.now(),
|
||||||
|
type: 'heading-2' as const,
|
||||||
|
content: 'How It Works'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-list-' + Date.now(),
|
||||||
|
type: 'list' as const,
|
||||||
|
content: 'Hover over any block to see the drag handle\nClick and hold the handle to start dragging\nDrag to reorder blocks\nRelease to drop in the new position',
|
||||||
|
metadata: { listType: 'ordered' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-quote-' + Date.now(),
|
||||||
|
type: 'quote' as const,
|
||||||
|
content: 'The drag and drop feature makes it easy to reorganize your content without cutting and pasting.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-code-' + Date.now(),
|
||||||
|
type: 'code' as const,
|
||||||
|
content: '// Example: The blocks can be reordered\nconst blocks = editor.exportBlocks();\n// Rearrange blocks array\neditor.importBlocks(blocks);'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-divider-' + Date.now(),
|
||||||
|
type: 'divider' as const,
|
||||||
|
content: ' '
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drag-footer-' + Date.now(),
|
||||||
|
type: 'paragraph' as const,
|
||||||
|
content: 'Note: Divider blocks cannot be dragged, but other blocks can be moved around them.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
dragDemoEditor.importBlocks(dragDemoBlocks);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
// Setup button handlers for programmatic demo
|
// Setup button handlers for programmatic demo
|
||||||
const generateReportBtn = elementArg.querySelector('#generate-report-btn');
|
const generateReportBtn = elementArg.querySelector('#generate-report-btn');
|
||||||
const generateRecipeBtn = elementArg.querySelector('#generate-recipe-btn');
|
const generateRecipeBtn = elementArg.querySelector('#generate-recipe-btn');
|
||||||
@@ -413,6 +465,10 @@ export const demoFunc = () => html`
|
|||||||
<span class="feature-icon">✓</span>
|
<span class="feature-icon">✓</span>
|
||||||
<span>Block-based editing</span>
|
<span>Block-based editing</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">✓</span>
|
||||||
|
<span>Drag & drop reordering</span>
|
||||||
|
</div>
|
||||||
<div class="feature-item">
|
<div class="feature-item">
|
||||||
<span class="feature-icon">✓</span>
|
<span class="feature-icon">✓</span>
|
||||||
<span>HTML & Markdown output</span>
|
<span>HTML & Markdown output</span>
|
||||||
@@ -479,6 +535,28 @@ export const demoFunc = () => html`
|
|||||||
></dees-input-wysiwyg>
|
></dees-input-wysiwyg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section">
|
||||||
|
<h3>🔀 Drag & Drop Reordering</h3>
|
||||||
|
<p>Easily rearrange your content blocks by dragging them. Hover over any block to reveal the drag handle on the left side.</p>
|
||||||
|
|
||||||
|
<dees-input-wysiwyg
|
||||||
|
id="drag-demo"
|
||||||
|
.label=${'Drag & Drop Demo'}
|
||||||
|
.description=${'Try dragging blocks to reorder them - hover to see drag handles'}
|
||||||
|
></dees-input-wysiwyg>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; padding: 12px; background: #f0f8ff; border-radius: 8px; font-size: 14px; line-height: 1.6;">
|
||||||
|
<strong>💡 Tips:</strong>
|
||||||
|
<ul style="margin: 8px 0 0 0; padding-left: 24px;">
|
||||||
|
<li>Hover over any block to see the drag handle (⋮⋮) on the left</li>
|
||||||
|
<li>Click and hold the drag handle to start dragging</li>
|
||||||
|
<li>Blue indicators show where the block will be dropped</li>
|
||||||
|
<li>Divider blocks cannot be dragged</li>
|
||||||
|
<li>The editor maintains focus on the moved block after dropping</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h3>📚 Tutorial & Documentation</h3>
|
<h3>📚 Tutorial & Documentation</h3>
|
||||||
<p>Create comprehensive tutorials and documentation with code examples, lists, and structured content.</p>
|
<p>Create comprehensive tutorials and documentation with code examples, lists, and structured content.</p>
|
||||||
|
@@ -60,6 +60,15 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
@state()
|
@state()
|
||||||
private slashMenuSelectedIndex: number = 0;
|
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 editorContentRef: HTMLDivElement;
|
||||||
private isComposing: boolean = false;
|
private isComposing: boolean = false;
|
||||||
private saveTimeout: any = null;
|
private saveTimeout: any = null;
|
||||||
@@ -73,26 +82,35 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||||
// Set initial content for blocks
|
// Set initial content for blocks after a brief delay to ensure DOM is ready
|
||||||
this.setBlockContents();
|
await this.updateComplete;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setBlockContents();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProperties: Map<string, any>) {
|
updated(changedProperties: Map<string, any>) {
|
||||||
// When blocks change (e.g., from setValue), update DOM content
|
// When blocks change (e.g., from setValue), update DOM content
|
||||||
if (changedProperties.has('blocks')) {
|
if (changedProperties.has('blocks')) {
|
||||||
this.setBlockContents();
|
// Wait for render to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setBlockContents();
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setBlockContents() {
|
private setBlockContents() {
|
||||||
// Only set content for blocks that aren't being edited
|
// Only set content for blocks that aren't being edited
|
||||||
this.blocks.forEach(block => {
|
this.blocks.forEach(block => {
|
||||||
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`) as HTMLDivElement;
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${block.id}"]`);
|
||||||
if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') {
|
if (wrapperElement) {
|
||||||
if (block.type === 'list') {
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
||||||
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
|
if (blockElement && document.activeElement !== blockElement && block.type !== 'divider') {
|
||||||
} else {
|
if (block.type === 'list') {
|
||||||
blockElement.textContent = block.content;
|
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>
|
></dees-label>
|
||||||
<div class="wysiwyg-container">
|
<div class="wysiwyg-container">
|
||||||
<div
|
<div
|
||||||
class="editor-content"
|
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
|
||||||
@click="${this.handleEditorClick}"
|
@click="${this.handleEditorClick}"
|
||||||
>
|
>
|
||||||
${this.blocks.map(block => this.renderBlock(block))}
|
${this.blocks.map(block => this.renderBlock(block))}
|
||||||
@@ -119,15 +137,35 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
private renderBlock(block: IBlock): TemplateResult {
|
private renderBlock(block: IBlock): TemplateResult {
|
||||||
const isSelected = this.selectedBlockId === block.id;
|
const isSelected = this.selectedBlockId === block.id;
|
||||||
|
const isDragging = this.draggedBlockId === block.id;
|
||||||
|
const isDragOver = this.dragOverBlockId === block.id;
|
||||||
|
|
||||||
return WysiwygBlocks.renderBlock(block, isSelected, {
|
return html`
|
||||||
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
|
<div
|
||||||
onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block),
|
class="block-wrapper ${isDragging ? 'dragging' : ''} ${isDragOver && this.dragOverPosition === 'before' ? 'drag-over-before' : ''} ${isDragOver && this.dragOverPosition === 'after' ? 'drag-over-after' : ''}"
|
||||||
onFocus: () => this.handleBlockFocus(block),
|
data-block-id="${block.id}"
|
||||||
onBlur: () => this.handleBlockBlur(block),
|
@dragover="${(e: DragEvent) => this.handleDragOver(e, block)}"
|
||||||
onCompositionStart: () => this.isComposing = true,
|
@drop="${(e: DragEvent) => this.handleDrop(e, block)}"
|
||||||
onCompositionEnd: () => this.isComposing = false,
|
@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[] {
|
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||||
@@ -611,4 +649,84 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}
|
}
|
||||||
this.importBlocks(state.blocks);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
@@ -61,6 +61,7 @@ export class WysiwygBlocks {
|
|||||||
@blur="${handlers.onBlur}"
|
@blur="${handlers.onBlur}"
|
||||||
@compositionstart="${handlers.onCompositionStart}"
|
@compositionstart="${handlers.onCompositionStart}"
|
||||||
@compositionend="${handlers.onCompositionEnd}"
|
@compositionend="${handlers.onCompositionEnd}"
|
||||||
|
.textContent="${block.content}"
|
||||||
></div>
|
></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@@ -282,4 +282,74 @@ export const wysiwygStyles = css`
|
|||||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
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;
|
||||||
|
}
|
||||||
`;
|
`;
|
Reference in New Issue
Block a user