feat: Add WYSIWYG editor components and utilities
- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
This commit is contained in:
991
ts_web/elements/dees-input-wysiwyg/dees-input-wysiwyg.ts
Normal file
991
ts_web/elements/dees-input-wysiwyg/dees-input-wysiwyg.ts
Normal file
@@ -0,0 +1,991 @@
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import { demoFunc } from '../dees-input-wysiwyg.demo.js';
|
||||
import { DeesModal } from '../dees-modal.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
static as html,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import {
|
||||
type IBlock,
|
||||
type OutputFormat,
|
||||
wysiwygStyles,
|
||||
WysiwygConverters,
|
||||
WysiwygShortcuts,
|
||||
WysiwygFormatting,
|
||||
WysiwygBlockOperations,
|
||||
WysiwygInputHandler,
|
||||
WysiwygKeyboardHandler,
|
||||
WysiwygDragDropHandler,
|
||||
WysiwygModalManager,
|
||||
WysiwygHistory,
|
||||
WysiwygSelection,
|
||||
DeesSlashMenu,
|
||||
DeesFormattingMenu
|
||||
} 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()
|
||||
public blocks: IBlock[] = [
|
||||
{
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type: 'paragraph',
|
||||
content: '',
|
||||
}
|
||||
];
|
||||
|
||||
// Not using @state to avoid re-renders when selection changes
|
||||
public selectedBlockId: string | null = null;
|
||||
|
||||
// Slash menu is now globally rendered
|
||||
public slashMenu = DeesSlashMenu.getInstance();
|
||||
|
||||
@state()
|
||||
public draggedBlockId: string | null = null;
|
||||
|
||||
@state()
|
||||
public dragOverBlockId: string | null = null;
|
||||
|
||||
@state()
|
||||
public dragOverPosition: 'before' | 'after' | null = null;
|
||||
|
||||
// Formatting menu is now globally rendered
|
||||
public formattingMenu = DeesFormattingMenu.getInstance();
|
||||
|
||||
@state()
|
||||
private selectedText: string = '';
|
||||
|
||||
public editorContentRef: HTMLDivElement;
|
||||
public isComposing: boolean = false;
|
||||
|
||||
// Handler instances
|
||||
public blockOperations: WysiwygBlockOperations;
|
||||
private inputHandler: WysiwygInputHandler;
|
||||
private keyboardHandler: WysiwygKeyboardHandler;
|
||||
private dragDropHandler: WysiwygDragDropHandler;
|
||||
private history: WysiwygHistory;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
wysiwygStyles
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Initialize handlers
|
||||
this.blockOperations = new WysiwygBlockOperations(this);
|
||||
this.inputHandler = new WysiwygInputHandler(this);
|
||||
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
||||
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
||||
this.history = new WysiwygHistory();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
// Selection listeners are now handled at block level
|
||||
// Clean up handlers
|
||||
this.inputHandler?.destroy();
|
||||
// Clean up blur timeout
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
this.updateValue();
|
||||
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||
|
||||
// Add click handler to editor content
|
||||
if (this.editorContentRef) {
|
||||
this.editorContentRef.addEventListener('click', (e) => this.handleEditorClick(e));
|
||||
}
|
||||
|
||||
// We now rely on block-level selection detection
|
||||
// No global selection listener needed
|
||||
|
||||
// Listen for custom selection events from blocks
|
||||
this.addEventListener('block-text-selected', (e: CustomEvent) => {
|
||||
|
||||
if (!this.slashMenu.visible && e.detail.hasSelection && e.detail.text.length > 0) {
|
||||
this.selectedText = e.detail.text;
|
||||
|
||||
// Use the rect from the event if available
|
||||
if (e.detail.rect) {
|
||||
const coords = {
|
||||
x: e.detail.rect.left + (e.detail.rect.width / 2),
|
||||
y: Math.max(45, e.detail.rect.top - 45)
|
||||
};
|
||||
|
||||
|
||||
// Show the formatting menu at the calculated position
|
||||
this.formattingMenu.show(
|
||||
coords,
|
||||
async (command: string) => await this.applyFormat(command)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Hide formatting menu when clicking outside
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
// Check if click is on the formatting menu itself
|
||||
const formattingMenuElement = this.formattingMenu.shadowRoot?.querySelector('.formatting-menu');
|
||||
if (formattingMenuElement && formattingMenuElement.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have an active selection
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) {
|
||||
// Don't hide if we still have a selection
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the menu
|
||||
if (this.formattingMenu.visible) {
|
||||
this.hideFormattingMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Add global keyboard listener for undo/redo
|
||||
this.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Check if the event is from within our editor
|
||||
const target = e.target as HTMLElement;
|
||||
if (!this.contains(target) && !this.shadowRoot?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle undo/redo
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
this.undo();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
this.redo();
|
||||
}
|
||||
});
|
||||
|
||||
// Save initial state to history
|
||||
this.history.saveState(this.blocks, this.selectedBlockId);
|
||||
|
||||
// Render blocks programmatically
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all blocks programmatically without triggering re-renders
|
||||
*/
|
||||
public renderBlocksProgrammatically() {
|
||||
if (!this.editorContentRef) return;
|
||||
|
||||
// Clear existing blocks
|
||||
this.editorContentRef.innerHTML = '';
|
||||
|
||||
// Create and append block elements
|
||||
this.blocks.forEach(block => {
|
||||
const blockWrapper = this.createBlockElement(block);
|
||||
this.editorContentRef.appendChild(blockWrapper);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a block element programmatically
|
||||
*/
|
||||
public createBlockElement(block: IBlock): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'block-wrapper';
|
||||
wrapper.setAttribute('data-block-id', block.id);
|
||||
|
||||
// Add drag handle for non-divider blocks
|
||||
if (block.type !== 'divider') {
|
||||
const dragHandle = document.createElement('div');
|
||||
dragHandle.className = 'drag-handle';
|
||||
dragHandle.draggable = true;
|
||||
dragHandle.addEventListener('dragstart', (e) => this.dragDropHandler.handleDragStart(e, block));
|
||||
wrapper.appendChild(dragHandle);
|
||||
}
|
||||
|
||||
// Create the block component
|
||||
const blockComponent = document.createElement('dees-wysiwyg-block') as any;
|
||||
blockComponent.block = block;
|
||||
blockComponent.isSelected = this.selectedBlockId === block.id;
|
||||
blockComponent.wysiwygComponent = this; // Pass parent reference
|
||||
blockComponent.handlers = {
|
||||
onInput: (e: InputEvent) => this.inputHandler.handleBlockInput(e, block),
|
||||
onKeyDown: (e: KeyboardEvent) => this.keyboardHandler.handleBlockKeyDown(e, block),
|
||||
onFocus: () => this.handleBlockFocus(block),
|
||||
onBlur: () => this.handleBlockBlur(block),
|
||||
onCompositionStart: () => this.isComposing = true,
|
||||
onCompositionEnd: () => this.isComposing = false,
|
||||
onMouseUp: (e: MouseEvent) => this.handleTextSelection(e),
|
||||
onRequestUpdate: () => this.updateBlockElement(block.id),
|
||||
};
|
||||
wrapper.appendChild(blockComponent);
|
||||
|
||||
// Remove settings button - context menu will handle this
|
||||
|
||||
// Add drag event listeners
|
||||
wrapper.addEventListener('dragover', (e) => this.dragDropHandler.handleDragOver(e, block));
|
||||
wrapper.addEventListener('drop', (e) => this.dragDropHandler.handleDrop(e, block));
|
||||
wrapper.addEventListener('dragleave', () => this.dragDropHandler.handleDragLeave(block));
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific block element
|
||||
*/
|
||||
public updateBlockElement(blockId: string) {
|
||||
const block = this.blocks.find(b => b.id === blockId);
|
||||
if (!block) return;
|
||||
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (!wrapper) return;
|
||||
|
||||
// Replace with new element
|
||||
const newWrapper = this.createBlockElement(block);
|
||||
wrapper.replaceWith(newWrapper);
|
||||
}
|
||||
|
||||
|
||||
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' : ''}"
|
||||
id="editor-content"
|
||||
>
|
||||
<!-- Blocks will be rendered programmatically -->
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Old renderBlock method removed - using programmatic rendering instead
|
||||
|
||||
|
||||
|
||||
|
||||
public handleSlashMenuKeyboard(e: KeyboardEvent) {
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.slashMenu.navigate('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.slashMenu.navigate('up');
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
this.slashMenu.selectCurrent();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeSlashMenu(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public closeSlashMenu(clearSlash: boolean = false) {
|
||||
if (clearSlash && 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) {
|
||||
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent) {
|
||||
const content = blockComponent.getContent();
|
||||
if (content.startsWith('/')) {
|
||||
// Remove the entire slash command (slash + any filter text)
|
||||
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
|
||||
blockComponent.setContent(cleanContent);
|
||||
currentBlock.content = cleanContent;
|
||||
|
||||
// Focus and set cursor at beginning
|
||||
requestAnimationFrame(() => {
|
||||
blockComponent.focusWithCursor(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.slashMenu.hide();
|
||||
}
|
||||
|
||||
private handleBlockFocus(block: IBlock) {
|
||||
// Clear any pending blur timeout when focusing
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
this.blurTimeout = null;
|
||||
}
|
||||
|
||||
const prevSelectedId = this.selectedBlockId;
|
||||
this.selectedBlockId = block.id;
|
||||
|
||||
// Only update selection UI if it changed
|
||||
if (prevSelectedId !== block.id) {
|
||||
// Update the previous block's selection state
|
||||
if (prevSelectedId) {
|
||||
const prevWrapper = this.shadowRoot?.querySelector(`[data-block-id="${prevSelectedId}"]`);
|
||||
const prevBlockComponent = prevWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (prevBlockComponent) {
|
||||
prevBlockComponent.isSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the new block's selection state
|
||||
const wrapper = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
blockComponent.isSelected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private blurTimeout: any = null;
|
||||
|
||||
private handleBlockBlur(block: IBlock) {
|
||||
// Clear any existing blur timeout
|
||||
if (this.blurTimeout) {
|
||||
clearTimeout(this.blurTimeout);
|
||||
}
|
||||
|
||||
// Don't update value if slash menu is visible
|
||||
if (this.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync content from the block that's losing focus
|
||||
const wrapperElement = this.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getContent) {
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Delay the blur handling to avoid interfering with typing
|
||||
this.blurTimeout = setTimeout(() => {
|
||||
// Check if we've refocused on another block
|
||||
const activeElement = this.shadowRoot?.activeElement;
|
||||
const isBlockFocused = activeElement?.classList.contains('block');
|
||||
|
||||
if (!isBlockFocused) {
|
||||
// Only update value if we're truly blurring away from all blocks
|
||||
this.updateValue();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Don't immediately clear selectedBlockId or close menus
|
||||
// Let click handlers decide what to do
|
||||
}
|
||||
|
||||
private handleEditorClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Close slash menu if clicking outside of it
|
||||
if (this.slashMenu.visible) {
|
||||
this.closeSlashMenu(true);
|
||||
}
|
||||
|
||||
// Focus last block if clicking on empty editor area
|
||||
if (target.classList.contains('editor-content')) {
|
||||
const lastBlock = this.blocks[this.blocks.length - 1];
|
||||
this.blockOperations.focusBlock(lastBlock.id, lastBlock.type === 'divider' || lastBlock.type === 'image' ? undefined : 'end');
|
||||
}
|
||||
}
|
||||
|
||||
private createNewBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
|
||||
return {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type,
|
||||
content,
|
||||
...(metadata && { metadata })
|
||||
};
|
||||
}
|
||||
|
||||
private async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
|
||||
const blockIndex = this.blocks.findIndex(b => b.id === afterBlock.id);
|
||||
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
||||
|
||||
// Insert the new block element programmatically
|
||||
const afterWrapper = this.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
|
||||
if (afterWrapper) {
|
||||
const newWrapper = this.createBlockElement(newBlock);
|
||||
afterWrapper.insertAdjacentElement('afterend', newWrapper);
|
||||
}
|
||||
|
||||
this.updateValue();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await this.blockOperations.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
public async insertBlock(type: IBlock['type']) {
|
||||
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||
|
||||
if (!currentBlock) {
|
||||
this.closeSlashMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component to extract clean content
|
||||
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
// Clear the slash command from the content before transforming
|
||||
if (blockComponent) {
|
||||
const content = blockComponent.getContent();
|
||||
if (content.startsWith('/')) {
|
||||
// Remove the slash and any filter text (including non-word characters)
|
||||
const cleanContent = content.replace(/^\/[^\s]*\s*/, '').trim();
|
||||
blockComponent.setContent(cleanContent);
|
||||
currentBlock.content = cleanContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu
|
||||
this.closeSlashMenu(false);
|
||||
|
||||
// If it's a code block, default to TypeScript
|
||||
if (type === 'code') {
|
||||
currentBlock.metadata = { language: 'typescript' };
|
||||
}
|
||||
|
||||
// Transform the current block
|
||||
currentBlock.type = type;
|
||||
currentBlock.content = currentBlock.content || '';
|
||||
|
||||
if (type === 'divider') {
|
||||
currentBlock.content = ' ';
|
||||
const newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(currentBlock, newBlock);
|
||||
} else if (type === 'list') {
|
||||
currentBlock.metadata = { listType: 'bullet' };
|
||||
// For lists, ensure we start with empty content
|
||||
currentBlock.content = '';
|
||||
} else if (type === 'image') {
|
||||
// For image blocks, clear content and set empty metadata
|
||||
currentBlock.content = '';
|
||||
currentBlock.metadata = { url: '', loading: false };
|
||||
} else if (type === 'youtube') {
|
||||
// For YouTube blocks, clear content and set empty metadata
|
||||
currentBlock.content = '';
|
||||
currentBlock.metadata = { videoId: '', url: '' };
|
||||
} else if (type === 'markdown') {
|
||||
// For Markdown blocks, preserve content and default to edit mode
|
||||
currentBlock.metadata = { showPreview: false };
|
||||
} else if (type === 'html') {
|
||||
// For HTML blocks, preserve content and default to edit mode
|
||||
currentBlock.metadata = { showPreview: false };
|
||||
} else if (type === 'attachment') {
|
||||
// For attachment blocks, clear content and set empty files array
|
||||
currentBlock.content = '';
|
||||
currentBlock.metadata = { files: [] };
|
||||
} else {
|
||||
// For all other block types, ensure content is clean
|
||||
currentBlock.content = currentBlock.content || '';
|
||||
}
|
||||
|
||||
// Update the block element programmatically
|
||||
this.updateBlockElement(currentBlock.id);
|
||||
this.updateValue();
|
||||
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Focus the block after rendering
|
||||
if (type === 'list') {
|
||||
this.blockOperations.focusBlock(currentBlock.id, 'start');
|
||||
// Additional list-specific focus handling
|
||||
requestAnimationFrame(() => {
|
||||
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
blockComponent.focusListItem();
|
||||
}
|
||||
});
|
||||
} else if (type !== 'divider' && type !== 'image' && type !== 'youtube' && type !== 'markdown' && type !== 'html' && type !== 'attachment') {
|
||||
this.blockOperations.focusBlock(currentBlock.id, 'start');
|
||||
} else if (type === 'image' || type === 'youtube' || type === 'markdown' || type === 'html' || type === 'attachment') {
|
||||
// Focus the non-editable block
|
||||
this.blockOperations.focusBlock(currentBlock.id);
|
||||
}
|
||||
}
|
||||
|
||||
public updateValue() {
|
||||
if (this.outputFormat === 'html') {
|
||||
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
|
||||
} else {
|
||||
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
||||
}
|
||||
this.changeSubject.next(this.value);
|
||||
|
||||
// Save to history (debounced)
|
||||
this.saveToHistory(true);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Re-render blocks programmatically if we have the editor
|
||||
if (this.editorContentRef) {
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Re-render blocks programmatically if we have the editor
|
||||
if (this.editorContentRef) {
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
private handleDragEnd(): void {
|
||||
// Remove all drag-related classes
|
||||
if (this.draggedBlockId) {
|
||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${this.draggedBlockId}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all drag-over classes
|
||||
const allWrappers = this.editorContentRef.querySelectorAll('.block-wrapper');
|
||||
allWrappers.forEach(wrapper => {
|
||||
wrapper.classList.remove('drag-over-before', 'drag-over-after');
|
||||
});
|
||||
|
||||
// Remove dragging class from editor content
|
||||
this.editorContentRef.classList.remove('dragging');
|
||||
|
||||
this.draggedBlockId = null;
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
}
|
||||
|
||||
public 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);
|
||||
|
||||
// Re-render blocks programmatically to reflect the new order
|
||||
this.renderBlocksProgrammatically();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
private handleTextSelection(_e: MouseEvent): void {
|
||||
// Don't interfere with slash menu
|
||||
if (this.slashMenu.visible) return;
|
||||
|
||||
// Let the block component handle selection via custom event
|
||||
}
|
||||
|
||||
|
||||
|
||||
private updateFormattingMenuPosition(): void {
|
||||
|
||||
// Get all shadow roots
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
|
||||
|
||||
// Find all block shadow roots
|
||||
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
|
||||
blockWrappers?.forEach(wrapper => {
|
||||
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
|
||||
if (blockComponent?.shadowRoot) {
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
}
|
||||
});
|
||||
|
||||
const coords = WysiwygFormatting.getSelectionCoordinates(...shadowRoots);
|
||||
|
||||
if (coords) {
|
||||
// Show the global formatting menu at absolute coordinates
|
||||
this.formattingMenu.show(
|
||||
{ x: coords.x, y: coords.y },
|
||||
async (command: string) => await this.applyFormat(command)
|
||||
);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
private hideFormattingMenu(): void {
|
||||
this.formattingMenu.hide();
|
||||
this.selectedText = '';
|
||||
}
|
||||
|
||||
public async applyFormat(command: string): Promise<void> {
|
||||
// Get all shadow roots
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
|
||||
|
||||
// Find all block shadow roots
|
||||
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
|
||||
blockWrappers?.forEach(wrapper => {
|
||||
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
|
||||
if (blockComponent?.shadowRoot) {
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
}
|
||||
});
|
||||
|
||||
// Get selection info using Shadow DOM-aware utilities
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return;
|
||||
|
||||
// Find which block contains the selection
|
||||
let targetBlock: IBlock | undefined;
|
||||
let targetBlockComponent: any;
|
||||
|
||||
const wrappers = this.shadowRoot!.querySelectorAll('.block-wrapper');
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
const wrapper = wrappers[i];
|
||||
const blockComponent = wrapper.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent?.shadowRoot) {
|
||||
const block = blockComponent.shadowRoot.querySelector('.block');
|
||||
if (block && (
|
||||
block.contains(selectionInfo.startContainer) ||
|
||||
block.contains(selectionInfo.endContainer)
|
||||
)) {
|
||||
const blockId = wrapper.getAttribute('data-block-id');
|
||||
targetBlock = this.blocks.find(b => b.id === blockId);
|
||||
targetBlockComponent = blockComponent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetBlock || !targetBlockComponent) return;
|
||||
|
||||
// Create a range from our selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
|
||||
// Handle link command specially
|
||||
if (command === 'link') {
|
||||
const url = await this.showLinkDialog();
|
||||
if (!url) {
|
||||
// User cancelled - restore focus to block
|
||||
targetBlockComponent.focus();
|
||||
return;
|
||||
}
|
||||
// Apply link format
|
||||
WysiwygFormatting.applyFormat(command, url, range, shadowRoots);
|
||||
} else {
|
||||
// Apply the format
|
||||
WysiwygFormatting.applyFormat(command, undefined, range, shadowRoots);
|
||||
}
|
||||
|
||||
// Update content after a microtask to ensure DOM is updated
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Force content update
|
||||
targetBlock.content = targetBlockComponent.getContent();
|
||||
|
||||
// Update value to persist changes
|
||||
this.updateValue();
|
||||
|
||||
// Restore focus to the block
|
||||
targetBlockComponent.focus();
|
||||
|
||||
// For link command, close the formatting menu
|
||||
if (command === 'link') {
|
||||
this.hideFormattingMenu();
|
||||
} else {
|
||||
// Let selection handler update menu position
|
||||
this.selectedText = '';
|
||||
}
|
||||
}
|
||||
|
||||
private async showLinkDialog(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let linkUrl: string | null = null;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Add Link',
|
||||
content: html`
|
||||
<style>
|
||||
.link-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
background: var(--dees-color-input);
|
||||
color: var(--dees-color-text);
|
||||
margin: 16px 0;
|
||||
}
|
||||
.link-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
</style>
|
||||
<input
|
||||
class="link-input"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
@keydown="${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const input = e.target as HTMLInputElement;
|
||||
linkUrl = input.value;
|
||||
// Find and click the OK button
|
||||
const modal = input.closest('dees-modal');
|
||||
if (modal) {
|
||||
const okButton = modal.shadowRoot?.querySelector('.bottomButton:last-child') as HTMLElement;
|
||||
if (okButton) okButton.click();
|
||||
}
|
||||
}
|
||||
}}"
|
||||
@input="${(e: InputEvent) => {
|
||||
linkUrl = (e.target as HTMLInputElement).value;
|
||||
}}"
|
||||
/>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Add Link',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(linkUrl);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Focus the input after modal is shown
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last action
|
||||
*/
|
||||
private undo(): void {
|
||||
const state = this.history.undo();
|
||||
if (state) {
|
||||
this.restoreState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the next action
|
||||
*/
|
||||
private redo(): void {
|
||||
const state = this.history.redo();
|
||||
if (state) {
|
||||
this.restoreState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore editor state from history
|
||||
*/
|
||||
private restoreState(state: { blocks: IBlock[]; selectedBlockId: string | null; cursorPosition?: { blockId: string; offset: number } }): void {
|
||||
// Update blocks
|
||||
this.blocks = state.blocks;
|
||||
this.selectedBlockId = state.selectedBlockId;
|
||||
|
||||
// Re-render blocks
|
||||
this.renderBlocksProgrammatically();
|
||||
|
||||
// Restore cursor position if available
|
||||
if (state.cursorPosition) {
|
||||
setTimeout(() => {
|
||||
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${state.cursorPosition!.blockId}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
blockComponent.focusWithCursor(state.cursorPosition!.offset);
|
||||
}
|
||||
}, 50);
|
||||
} else if (state.selectedBlockId) {
|
||||
// Just focus the selected block
|
||||
setTimeout(() => {
|
||||
this.blockOperations.focusBlock(state.selectedBlockId!);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Update value
|
||||
this.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to history with cursor position
|
||||
*/
|
||||
|
||||
public saveToHistory(debounce: boolean = true): void {
|
||||
// Get current cursor position if a block is focused
|
||||
let cursorPosition: { blockId: string; offset: number } | undefined;
|
||||
|
||||
if (this.selectedBlockId) {
|
||||
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${this.selectedBlockId}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent && typeof blockComponent.getCursorPosition === 'function') {
|
||||
const editableElement = blockComponent.shadowRoot?.querySelector('.block') as HTMLElement;
|
||||
if (editableElement) {
|
||||
const offset = blockComponent.getCursorPosition(editableElement);
|
||||
if (offset !== null) {
|
||||
cursorPosition = {
|
||||
blockId: this.selectedBlockId,
|
||||
offset
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debounce) {
|
||||
this.history.saveState(this.blocks, this.selectedBlockId, cursorPosition);
|
||||
} else {
|
||||
this.history.saveCheckpoint(this.blocks, this.selectedBlockId, cursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user