759 lines
24 KiB
TypeScript
759 lines
24 KiB
TypeScript
import { DeesInputBase } from '../dees-input-base.js';
|
|
import { demoFunc } from '../dees-input-wysiwyg.demo.js';
|
|
|
|
import {
|
|
customElement,
|
|
type TemplateResult,
|
|
property,
|
|
html,
|
|
cssManager,
|
|
state,
|
|
} from '@design.estate/dees-element';
|
|
|
|
import {
|
|
type IBlock,
|
|
type OutputFormat,
|
|
wysiwygStyles,
|
|
WysiwygConverters,
|
|
WysiwygShortcuts,
|
|
WysiwygBlocks,
|
|
type ISlashMenuItem
|
|
} from './index.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-input-wysiwyg': DeesInputWysiwyg;
|
|
}
|
|
}
|
|
|
|
@customElement('dees-input-wysiwyg')
|
|
export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|
public static demo = demoFunc;
|
|
|
|
@property({ type: String })
|
|
public value: string = '';
|
|
|
|
@property({ type: String })
|
|
public outputFormat: OutputFormat = 'html';
|
|
|
|
@state()
|
|
private blocks: IBlock[] = [
|
|
{
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
}
|
|
];
|
|
|
|
@state()
|
|
private selectedBlockId: string | null = null;
|
|
|
|
@state()
|
|
private showSlashMenu: boolean = false;
|
|
|
|
@state()
|
|
private slashMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
|
|
|
@state()
|
|
private slashMenuFilter: 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;
|
|
|
|
public static styles = [
|
|
...DeesInputBase.baseStyles,
|
|
cssManager.defaultStyles,
|
|
wysiwygStyles
|
|
];
|
|
|
|
async firstUpdated() {
|
|
this.updateValue();
|
|
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
|
// 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')) {
|
|
// Wait for render to complete
|
|
setTimeout(() => {
|
|
this.setBlockContents();
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
private setBlockContents() {
|
|
// Set content for blocks that aren't being edited and don't already have content
|
|
this.blocks.forEach(block => {
|
|
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') {
|
|
// Only set content if the element is empty or has placeholder text
|
|
const currentContent = blockElement.textContent || '';
|
|
const isEmpty = !currentContent || currentContent === 'Type \'/\' for commands...' ||
|
|
currentContent === 'Heading 1' || currentContent === 'Heading 2' ||
|
|
currentContent === 'Heading 3' || currentContent === 'Quote' ||
|
|
currentContent === 'Code block';
|
|
|
|
if (isEmpty && block.content) {
|
|
if (block.type === 'list') {
|
|
blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata);
|
|
} else {
|
|
blockElement.textContent = block.content;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
render(): TemplateResult {
|
|
return html`
|
|
<dees-label
|
|
.label="${this.label}"
|
|
.description="${this.description}"
|
|
.required="${this.required}"
|
|
></dees-label>
|
|
<div class="wysiwyg-container">
|
|
<div
|
|
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
|
|
@click="${this.handleEditorClick}"
|
|
>
|
|
${this.blocks.map(block => this.renderBlock(block))}
|
|
</div>
|
|
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderBlock(block: IBlock): TemplateResult {
|
|
const isSelected = this.selectedBlockId === block.id;
|
|
const isDragging = this.draggedBlockId === block.id;
|
|
const isDragOver = this.dragOverBlockId === block.id;
|
|
|
|
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[] {
|
|
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
|
return allItems.filter(item =>
|
|
this.slashMenuFilter === '' ||
|
|
item.label.toLowerCase().includes(this.slashMenuFilter.toLowerCase())
|
|
);
|
|
}
|
|
|
|
private renderSlashMenu(): TemplateResult {
|
|
const menuItems = this.getFilteredMenuItems();
|
|
|
|
return html`
|
|
<div
|
|
class="slash-menu"
|
|
style="top: ${this.slashMenuPosition.y}px; left: ${this.slashMenuPosition.x}px;"
|
|
>
|
|
${menuItems.map((item, index) => html`
|
|
<div
|
|
class="slash-menu-item ${index === this.slashMenuSelectedIndex ? 'selected' : ''}"
|
|
@click="${() => this.insertBlock(item.type as IBlock['type'])}"
|
|
@mouseenter="${() => this.slashMenuSelectedIndex = index}"
|
|
>
|
|
<span class="icon">${item.icon}</span>
|
|
<span>${item.label}</span>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private handleBlockInput(e: InputEvent, block: IBlock) {
|
|
if (this.isComposing) return;
|
|
|
|
const target = e.target as HTMLDivElement;
|
|
|
|
if (block.type === 'list') {
|
|
// Extract text from list items
|
|
const listItems = target.querySelectorAll('li');
|
|
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
|
// Preserve list type
|
|
const listElement = target.querySelector('ol, ul');
|
|
if (listElement) {
|
|
block.metadata = { listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet' };
|
|
}
|
|
} else {
|
|
block.content = target.textContent || '';
|
|
}
|
|
|
|
// Check for block type change intents
|
|
const detectedType = this.detectBlockTypeIntent(block.content);
|
|
|
|
// Only process if the detected type is different from current type
|
|
if (detectedType && detectedType.type !== block.type) {
|
|
e.preventDefault();
|
|
|
|
// Handle special cases
|
|
if (detectedType.type === 'list') {
|
|
block.type = 'list';
|
|
block.content = '';
|
|
block.metadata = { listType: detectedType.listType };
|
|
|
|
// Update list structure immediately
|
|
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
|
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
|
|
|
// Force update and focus
|
|
this.updateValue();
|
|
this.requestUpdate();
|
|
|
|
setTimeout(() => {
|
|
WysiwygBlocks.focusListItem(target);
|
|
}, 0);
|
|
|
|
return;
|
|
} else if (detectedType.type === 'divider') {
|
|
block.type = 'divider';
|
|
block.content = ' ';
|
|
|
|
// Create a new paragraph block after the divider
|
|
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
|
const newBlock: IBlock = {
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
};
|
|
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
|
|
|
setTimeout(() => {
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`);
|
|
if (wrapperElement) {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
this.updateValue();
|
|
this.requestUpdate();
|
|
return;
|
|
} else {
|
|
// For all other block types
|
|
block.type = detectedType.type;
|
|
block.content = '';
|
|
|
|
// Clear the DOM element immediately
|
|
target.textContent = '';
|
|
|
|
// Force update
|
|
this.updateValue();
|
|
this.requestUpdate();
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check for slash commands at the beginning of any block
|
|
if (block.content === '/' || (block.content.startsWith('/') && this.showSlashMenu)) {
|
|
// Only show menu on initial '/', or update filter if already showing
|
|
if (!this.showSlashMenu && block.content === '/') {
|
|
this.showSlashMenu = true;
|
|
this.slashMenuSelectedIndex = 0;
|
|
const rect = target.getBoundingClientRect();
|
|
const containerRect = this.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
|
|
this.slashMenuPosition = {
|
|
x: rect.left - containerRect.left,
|
|
y: rect.bottom - containerRect.top + 4
|
|
};
|
|
}
|
|
this.slashMenuFilter = block.content.slice(1);
|
|
} else if (!block.content.startsWith('/')) {
|
|
this.closeSlashMenu();
|
|
}
|
|
|
|
// Don't update value on every input - let the browser handle typing normally
|
|
// But schedule a save after a delay
|
|
if (this.saveTimeout) {
|
|
clearTimeout(this.saveTimeout);
|
|
}
|
|
this.saveTimeout = setTimeout(() => {
|
|
this.updateValue();
|
|
}, 1000); // Save after 1 second of inactivity
|
|
}
|
|
|
|
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
|
|
if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
|
this.handleSlashMenuKeyboard(e);
|
|
return;
|
|
}
|
|
|
|
// Handle Tab key for indentation
|
|
if (e.key === 'Tab') {
|
|
if (block.type === 'code') {
|
|
// Allow tab in code blocks
|
|
e.preventDefault();
|
|
document.execCommand('insertText', false, ' ');
|
|
return;
|
|
} else if (block.type === 'list') {
|
|
// Future: implement list indentation
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
if (block.type === 'list') {
|
|
// Handle Enter in lists differently
|
|
const target = e.target as HTMLDivElement;
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount > 0) {
|
|
const range = selection.getRangeAt(0);
|
|
const currentLi = range.startContainer.parentElement?.closest('li');
|
|
|
|
if (currentLi && currentLi.textContent === '') {
|
|
// Empty list item - exit list mode
|
|
e.preventDefault();
|
|
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
|
const newBlock: IBlock = {
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
};
|
|
|
|
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
|
|
|
setTimeout(() => {
|
|
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
|
if (newBlockElement) {
|
|
newBlockElement.focus();
|
|
}
|
|
});
|
|
}
|
|
// Otherwise, let the browser handle creating new list items
|
|
}
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
|
const newBlock: IBlock = {
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
};
|
|
|
|
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
|
|
|
this.updateValue();
|
|
|
|
setTimeout(() => {
|
|
const newBlockElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`) as HTMLDivElement;
|
|
if (newBlockElement) {
|
|
newBlockElement.focus();
|
|
}
|
|
});
|
|
} else if (e.key === 'Backspace' && block.content === '' && this.blocks.length > 1) {
|
|
e.preventDefault();
|
|
const blockIndex = this.blocks.findIndex(b => b.id === block.id);
|
|
if (blockIndex > 0) {
|
|
const prevBlock = this.blocks[blockIndex - 1];
|
|
this.blocks = this.blocks.filter(b => b.id !== block.id);
|
|
|
|
this.updateValue();
|
|
|
|
setTimeout(() => {
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${prevBlock.id}"]`);
|
|
if (wrapperElement && prevBlock.type !== 'divider') {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.focus();
|
|
WysiwygBlocks.setCursorToEnd(blockElement);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleSlashMenuKeyboard(e: KeyboardEvent) {
|
|
const menuItems = this.getFilteredMenuItems();
|
|
|
|
switch(e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
this.slashMenuSelectedIndex = (this.slashMenuSelectedIndex + 1) % menuItems.length;
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
|
|
? menuItems.length - 1
|
|
: this.slashMenuSelectedIndex - 1;
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (menuItems[this.slashMenuSelectedIndex]) {
|
|
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
this.closeSlashMenu();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private closeSlashMenu() {
|
|
if (this.showSlashMenu && this.selectedBlockId) {
|
|
// Clear the slash command from the content if menu is closing without selection
|
|
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
|
if (currentBlock && currentBlock.content.startsWith('/')) {
|
|
const blockElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`) as HTMLDivElement;
|
|
if (blockElement) {
|
|
// Clear the slash command text
|
|
blockElement.textContent = '';
|
|
currentBlock.content = '';
|
|
// Ensure cursor stays in the block
|
|
blockElement.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
this.showSlashMenu = false;
|
|
this.slashMenuFilter = '';
|
|
this.slashMenuSelectedIndex = 0;
|
|
}
|
|
|
|
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
|
// Check heading patterns
|
|
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
|
if (headingResult) {
|
|
return headingResult;
|
|
}
|
|
|
|
// Check list patterns
|
|
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
|
if (listResult) {
|
|
return listResult;
|
|
}
|
|
|
|
// Check quote pattern
|
|
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
|
return { type: 'quote' };
|
|
}
|
|
|
|
// Check code pattern
|
|
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
|
return { type: 'code' };
|
|
}
|
|
|
|
// Check divider pattern
|
|
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
|
return { type: 'divider' };
|
|
}
|
|
|
|
// Don't automatically revert to paragraph - blocks should keep their type
|
|
// unless explicitly changed by the user
|
|
return null;
|
|
}
|
|
|
|
private handleBlockFocus(block: IBlock) {
|
|
if (block.type !== 'divider') {
|
|
this.selectedBlockId = block.id;
|
|
}
|
|
}
|
|
|
|
private handleBlockBlur(block: IBlock) {
|
|
// Update value on blur to ensure it's saved
|
|
this.updateValue();
|
|
|
|
setTimeout(() => {
|
|
if (this.selectedBlockId === block.id) {
|
|
this.selectedBlockId = null;
|
|
}
|
|
// Don't close slash menu on blur if clicking on menu item
|
|
const activeElement = document.activeElement;
|
|
const slashMenu = this.shadowRoot?.querySelector('.slash-menu');
|
|
if (!slashMenu?.contains(activeElement as Node)) {
|
|
this.closeSlashMenu();
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
private handleEditorClick(e: MouseEvent) {
|
|
const target = e.target as HTMLElement;
|
|
if (target.classList.contains('editor-content')) {
|
|
const lastBlock = this.blocks[this.blocks.length - 1];
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${lastBlock.id}"]`);
|
|
if (wrapperElement && lastBlock.type !== 'divider') {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.focus();
|
|
WysiwygBlocks.setCursorToEnd(blockElement);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private insertBlock(type: IBlock['type']) {
|
|
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
|
|
const currentBlock = this.blocks[currentBlockIndex];
|
|
|
|
if (currentBlock && currentBlock.content.startsWith('/')) {
|
|
currentBlock.type = type;
|
|
currentBlock.content = '';
|
|
|
|
if (type === 'divider') {
|
|
currentBlock.content = ' ';
|
|
const newBlock: IBlock = {
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
};
|
|
this.blocks = [...this.blocks.slice(0, currentBlockIndex + 1), newBlock, ...this.blocks.slice(currentBlockIndex + 1)];
|
|
|
|
setTimeout(() => {
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`);
|
|
if (wrapperElement) {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.focus();
|
|
}
|
|
}
|
|
});
|
|
} else if (type === 'list') {
|
|
// Handle list type specially
|
|
currentBlock.metadata = { listType: 'bullet' }; // Default to bullet list
|
|
setTimeout(() => {
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
|
if (wrapperElement) {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.innerHTML = '<ul><li></li></ul>';
|
|
WysiwygBlocks.focusListItem(blockElement);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// Force update the contenteditable element
|
|
setTimeout(() => {
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
|
if (wrapperElement) {
|
|
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
|
if (blockElement) {
|
|
blockElement.textContent = '';
|
|
blockElement.focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
this.closeSlashMenu();
|
|
this.updateValue();
|
|
}
|
|
|
|
private updateValue() {
|
|
if (this.outputFormat === 'html') {
|
|
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
|
|
} else {
|
|
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
|
}
|
|
this.changeSubject.next(this.value);
|
|
}
|
|
|
|
public getValue(): string {
|
|
return this.value;
|
|
}
|
|
|
|
public setValue(value: string): void {
|
|
this.value = value;
|
|
|
|
if (this.outputFormat === 'html') {
|
|
this.blocks = WysiwygConverters.parseHtmlToBlocks(value);
|
|
} else {
|
|
this.blocks = WysiwygConverters.parseMarkdownToBlocks(value);
|
|
}
|
|
|
|
if (this.blocks.length === 0) {
|
|
this.blocks = [{
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
type: 'paragraph',
|
|
content: '',
|
|
}];
|
|
}
|
|
|
|
this.changeSubject.next(this.value);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Export the editor content as raw blocks (lossless)
|
|
*/
|
|
public exportBlocks(): IBlock[] {
|
|
return JSON.parse(JSON.stringify(this.blocks));
|
|
}
|
|
|
|
/**
|
|
* Import raw blocks (lossless)
|
|
*/
|
|
public importBlocks(blocks: IBlock[]): void {
|
|
this.blocks = JSON.parse(JSON.stringify(blocks));
|
|
this.updateValue();
|
|
this.requestUpdate();
|
|
}
|
|
|
|
/**
|
|
* Export content as HTML regardless of outputFormat setting
|
|
*/
|
|
public exportAsHtml(): string {
|
|
return WysiwygConverters.getHtmlOutput(this.blocks);
|
|
}
|
|
|
|
/**
|
|
* Export content as Markdown regardless of outputFormat setting
|
|
*/
|
|
public exportAsMarkdown(): string {
|
|
return WysiwygConverters.getMarkdownOutput(this.blocks);
|
|
}
|
|
|
|
/**
|
|
* Get a JSON representation of the editor state (for saving)
|
|
*/
|
|
public exportState(): { blocks: IBlock[], outputFormat: OutputFormat } {
|
|
return {
|
|
blocks: this.exportBlocks(),
|
|
outputFormat: this.outputFormat
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore editor state from JSON
|
|
*/
|
|
public importState(state: { blocks: IBlock[], outputFormat?: OutputFormat }): void {
|
|
if (state.outputFormat) {
|
|
this.outputFormat = state.outputFormat;
|
|
}
|
|
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);
|
|
}
|
|
} |