2025-06-23 17:36:39 +00:00
|
|
|
import { DeesInputBase } from '../dees-input-base.js';
|
|
|
|
import { demoFunc } from '../dees-input-wysiwyg.demo.js';
|
2025-06-23 21:28:58 +00:00
|
|
|
import { DeesModal } from '../dees-modal.js';
|
2025-06-23 17:36:39 +00:00
|
|
|
|
|
|
|
import {
|
|
|
|
customElement,
|
|
|
|
type TemplateResult,
|
|
|
|
property,
|
|
|
|
html,
|
|
|
|
cssManager,
|
|
|
|
state,
|
|
|
|
} from '@design.estate/dees-element';
|
|
|
|
|
|
|
|
import {
|
|
|
|
type IBlock,
|
|
|
|
type OutputFormat,
|
|
|
|
wysiwygStyles,
|
|
|
|
WysiwygConverters,
|
|
|
|
WysiwygShortcuts,
|
|
|
|
WysiwygBlocks,
|
2025-06-23 21:15:04 +00:00
|
|
|
type ISlashMenuItem,
|
2025-06-24 08:19:53 +00:00
|
|
|
WysiwygFormatting,
|
|
|
|
WysiwygBlockOperations,
|
|
|
|
WysiwygInputHandler,
|
|
|
|
WysiwygKeyboardHandler,
|
|
|
|
WysiwygDragDropHandler,
|
2025-06-24 10:45:06 +00:00
|
|
|
WysiwygModalManager,
|
|
|
|
DeesSlashMenu,
|
|
|
|
DeesFormattingMenu
|
2025-06-23 17:36:39 +00:00
|
|
|
} 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: '',
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Not using @state to avoid re-renders when selection changes
|
2025-06-23 17:36:39 +00:00
|
|
|
private selectedBlockId: string | null = null;
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Slash menu is now globally rendered
|
|
|
|
private slashMenu = DeesSlashMenu.getInstance();
|
2025-06-23 17:36:39 +00:00
|
|
|
|
2025-06-23 18:02:40 +00:00
|
|
|
@state()
|
|
|
|
private draggedBlockId: string | null = null;
|
|
|
|
|
|
|
|
@state()
|
|
|
|
private dragOverBlockId: string | null = null;
|
|
|
|
|
|
|
|
@state()
|
|
|
|
private dragOverPosition: 'before' | 'after' | null = null;
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Formatting menu is now globally rendered
|
|
|
|
private formattingMenu = DeesFormattingMenu.getInstance();
|
2025-06-23 21:15:04 +00:00
|
|
|
|
|
|
|
@state()
|
|
|
|
private selectedText: string = '';
|
|
|
|
|
2025-06-23 17:36:39 +00:00
|
|
|
private editorContentRef: HTMLDivElement;
|
|
|
|
private isComposing: boolean = false;
|
2025-06-23 21:15:04 +00:00
|
|
|
private selectionChangeHandler = () => this.handleSelectionChange();
|
2025-06-24 08:19:53 +00:00
|
|
|
|
|
|
|
// Handler instances
|
|
|
|
private blockOperations: WysiwygBlockOperations;
|
|
|
|
private inputHandler: WysiwygInputHandler;
|
|
|
|
private keyboardHandler: WysiwygKeyboardHandler;
|
|
|
|
private dragDropHandler: WysiwygDragDropHandler;
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// Content cache to avoid triggering re-renders during typing
|
|
|
|
private contentCache: Map<string, string> = new Map();
|
2025-06-23 17:36:39 +00:00
|
|
|
|
|
|
|
public static styles = [
|
|
|
|
...DeesInputBase.baseStyles,
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
wysiwygStyles
|
|
|
|
];
|
|
|
|
|
2025-06-24 08:19:53 +00:00
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
// Initialize handlers
|
|
|
|
this.blockOperations = new WysiwygBlockOperations(this);
|
|
|
|
this.inputHandler = new WysiwygInputHandler(this);
|
|
|
|
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
|
|
|
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
|
|
|
}
|
|
|
|
|
2025-06-23 21:15:04 +00:00
|
|
|
async connectedCallback() {
|
|
|
|
await super.connectedCallback();
|
|
|
|
}
|
|
|
|
|
|
|
|
async disconnectedCallback() {
|
|
|
|
await super.disconnectedCallback();
|
|
|
|
// Remove selection listener
|
|
|
|
document.removeEventListener('selectionchange', this.selectionChangeHandler);
|
2025-06-24 08:19:53 +00:00
|
|
|
// Clean up handlers
|
|
|
|
this.inputHandler?.destroy();
|
2025-06-24 11:06:02 +00:00
|
|
|
// Clean up blur timeout
|
|
|
|
if (this.blurTimeout) {
|
|
|
|
clearTimeout(this.blurTimeout);
|
|
|
|
this.blurTimeout = null;
|
|
|
|
}
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
|
|
|
|
2025-06-23 17:36:39 +00:00
|
|
|
async firstUpdated() {
|
|
|
|
this.updateValue();
|
|
|
|
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
2025-06-23 21:15:04 +00:00
|
|
|
|
|
|
|
// Add global selection listener
|
|
|
|
console.log('Adding selectionchange listener');
|
|
|
|
document.addEventListener('selectionchange', this.selectionChangeHandler);
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
// Listen for custom selection events from blocks
|
|
|
|
this.addEventListener('block-text-selected', (e: CustomEvent) => {
|
|
|
|
if (!this.slashMenu.visible) {
|
|
|
|
this.selectedText = e.detail.text;
|
|
|
|
this.updateFormattingMenuPosition();
|
|
|
|
}
|
|
|
|
});
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// Render blocks programmatically
|
|
|
|
this.renderBlocksProgrammatically();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Renders all blocks programmatically without triggering re-renders
|
|
|
|
*/
|
|
|
|
private 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
|
|
|
|
*/
|
|
|
|
private 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));
|
|
|
|
dragHandle.addEventListener('dragend', () => this.dragDropHandler.handleDragEnd());
|
|
|
|
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.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),
|
|
|
|
};
|
|
|
|
wrapper.appendChild(blockComponent);
|
|
|
|
|
|
|
|
// Add settings button for non-divider blocks
|
|
|
|
if (block.type !== 'divider') {
|
|
|
|
const settings = document.createElement('div');
|
|
|
|
settings.className = 'block-settings';
|
|
|
|
settings.innerHTML = `
|
|
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
|
|
<circle cx="12" cy="5" r="2"></circle>
|
|
|
|
<circle cx="12" cy="12" r="2"></circle>
|
|
|
|
<circle cx="12" cy="19" r="2"></circle>
|
|
|
|
</svg>
|
|
|
|
`;
|
|
|
|
settings.addEventListener('click', (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
|
|
|
|
this.updateValue();
|
|
|
|
// Re-render only the updated block
|
|
|
|
this.updateBlockElement(block.id);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
wrapper.appendChild(settings);
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
*/
|
|
|
|
private 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);
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
|
2025-06-23 17:36:39 +00:00
|
|
|
render(): TemplateResult {
|
|
|
|
return html`
|
|
|
|
<dees-label
|
|
|
|
.label="${this.label}"
|
|
|
|
.description="${this.description}"
|
|
|
|
.required="${this.required}"
|
|
|
|
></dees-label>
|
|
|
|
<div class="wysiwyg-container">
|
|
|
|
<div
|
2025-06-23 18:02:40 +00:00
|
|
|
class="editor-content ${this.draggedBlockId ? 'dragging' : ''}"
|
2025-06-23 17:36:39 +00:00
|
|
|
@click="${this.handleEditorClick}"
|
2025-06-24 11:06:02 +00:00
|
|
|
id="editor-content"
|
2025-06-23 17:36:39 +00:00
|
|
|
>
|
2025-06-24 11:06:02 +00:00
|
|
|
<!-- Blocks will be rendered programmatically -->
|
2025-06-23 17:36:39 +00:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Old renderBlock method removed - using programmatic rendering instead
|
2025-06-23 17:36:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private handleSlashMenuKeyboard(e: KeyboardEvent) {
|
|
|
|
switch(e.key) {
|
|
|
|
case 'ArrowDown':
|
|
|
|
e.preventDefault();
|
2025-06-24 10:45:06 +00:00
|
|
|
this.slashMenu.navigate('down');
|
2025-06-23 17:36:39 +00:00
|
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
|
|
e.preventDefault();
|
2025-06-24 10:45:06 +00:00
|
|
|
this.slashMenu.navigate('up');
|
2025-06-23 17:36:39 +00:00
|
|
|
break;
|
|
|
|
case 'Enter':
|
|
|
|
e.preventDefault();
|
2025-06-24 10:45:06 +00:00
|
|
|
this.slashMenu.selectCurrent();
|
2025-06-23 17:36:39 +00:00
|
|
|
break;
|
|
|
|
case 'Escape':
|
|
|
|
e.preventDefault();
|
2025-06-24 10:45:06 +00:00
|
|
|
this.closeSlashMenu(true);
|
2025-06-23 17:36:39 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
public closeSlashMenu(clearSlash: boolean = false) {
|
|
|
|
if (clearSlash && this.selectedBlockId) {
|
2025-06-23 17:36:39 +00:00
|
|
|
// Clear the slash command from the content if menu is closing without selection
|
|
|
|
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
2025-06-24 07:21:09 +00:00
|
|
|
if (currentBlock) {
|
|
|
|
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${currentBlock.id}"]`);
|
2025-06-24 10:45:06 +00:00
|
|
|
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);
|
|
|
|
});
|
2025-06-24 07:21:09 +00:00
|
|
|
}
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
this.slashMenu.hide();
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2025-06-24 11:06:02 +00:00
|
|
|
// Clear any pending blur timeout when focusing
|
|
|
|
if (this.blurTimeout) {
|
|
|
|
clearTimeout(this.blurTimeout);
|
|
|
|
this.blurTimeout = null;
|
|
|
|
}
|
|
|
|
|
2025-06-23 17:36:39 +00:00
|
|
|
if (block.type !== 'divider') {
|
2025-06-24 11:06:02 +00:00
|
|
|
const prevSelectedId = this.selectedBlockId;
|
2025-06-23 17:36:39 +00:00
|
|
|
this.selectedBlockId = block.id;
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
}
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
private blurTimeout: any = null;
|
|
|
|
|
2025-06-23 17:36:39 +00:00
|
|
|
private handleBlockBlur(block: IBlock) {
|
2025-06-24 11:06:02 +00:00
|
|
|
// Clear any existing blur timeout
|
|
|
|
if (this.blurTimeout) {
|
|
|
|
clearTimeout(this.blurTimeout);
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Don't update value if slash menu is visible
|
|
|
|
if (this.slashMenu.visible) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// 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);
|
2025-06-23 17:36:39 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Don't immediately clear selectedBlockId or close menus
|
|
|
|
// Let click handlers decide what to do
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private handleEditorClick(e: MouseEvent) {
|
|
|
|
const target = e.target as HTMLElement;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
// Close slash menu if clicking outside of it
|
|
|
|
if (this.slashMenu.visible) {
|
|
|
|
this.closeSlashMenu(true);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Focus last block if clicking on empty editor area
|
2025-06-23 17:36:39 +00:00
|
|
|
if (target.classList.contains('editor-content')) {
|
|
|
|
const lastBlock = this.blocks[this.blocks.length - 1];
|
2025-06-24 10:45:06 +00:00
|
|
|
if (lastBlock.type !== 'divider') {
|
|
|
|
this.blockOperations.focusBlock(lastBlock.id, 'end');
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 07:21:09 +00:00
|
|
|
private createNewBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
|
|
|
|
return {
|
|
|
|
id: WysiwygShortcuts.generateBlockId(),
|
|
|
|
type,
|
|
|
|
content,
|
|
|
|
...(metadata && { metadata })
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
private async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
|
2025-06-24 07:21:09 +00:00
|
|
|
const blockIndex = this.blocks.findIndex(b => b.id === afterBlock.id);
|
|
|
|
this.blocks = [...this.blocks.slice(0, blockIndex + 1), newBlock, ...this.blocks.slice(blockIndex + 1)];
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
|
2025-06-24 07:21:09 +00:00
|
|
|
this.updateValue();
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
if (focusNewBlock && newBlock.type !== 'divider') {
|
2025-06-24 11:06:02 +00:00
|
|
|
// Give DOM time to settle
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
2025-06-24 10:45:06 +00:00
|
|
|
await this.blockOperations.focusBlock(newBlock.id, 'start');
|
2025-06-24 07:21:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 08:19:53 +00:00
|
|
|
public async insertBlock(type: IBlock['type']) {
|
2025-06-24 10:45:06 +00:00
|
|
|
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;
|
2025-06-23 21:28:58 +00:00
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Close menu
|
|
|
|
this.closeSlashMenu(false);
|
|
|
|
|
|
|
|
// If it's a code block, ask for language
|
|
|
|
if (type === 'code') {
|
|
|
|
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
|
|
|
if (!language) {
|
|
|
|
return; // User cancelled
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
currentBlock.metadata = { language };
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// 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 {
|
|
|
|
// For all other block types, ensure content is clean
|
|
|
|
currentBlock.content = currentBlock.content || '';
|
|
|
|
}
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Update the block element programmatically
|
|
|
|
this.updateBlockElement(currentBlock.id);
|
2025-06-23 17:36:39 +00:00
|
|
|
this.updateValue();
|
2025-06-24 10:45:06 +00:00
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Give DOM time to settle
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
// 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') {
|
|
|
|
this.blockOperations.focusBlock(currentBlock.id, 'start');
|
|
|
|
}
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// Re-render blocks programmatically if we have the editor
|
|
|
|
if (this.editorContentRef) {
|
|
|
|
this.renderBlocksProgrammatically();
|
|
|
|
}
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|
2025-06-23 17:52:10 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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();
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// Re-render blocks programmatically if we have the editor
|
|
|
|
if (this.editorContentRef) {
|
|
|
|
this.renderBlocksProgrammatically();
|
|
|
|
}
|
2025-06-23 17:52:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2025-06-23 18:02:40 +00:00
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Add dragging class to the wrapper
|
2025-06-23 18:02:40 +00:00
|
|
|
setTimeout(() => {
|
2025-06-24 11:06:02 +00:00
|
|
|
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
|
|
|
if (wrapper) {
|
|
|
|
wrapper.classList.add('dragging');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add dragging class to editor content
|
|
|
|
this.editorContentRef.classList.add('dragging');
|
2025-06-23 18:02:40 +00:00
|
|
|
}, 10);
|
|
|
|
}
|
|
|
|
|
|
|
|
private handleDragEnd(): void {
|
2025-06-24 11:06:02 +00:00
|
|
|
// 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');
|
|
|
|
|
2025-06-23 18:02:40 +00:00
|
|
|
this.draggedBlockId = null;
|
|
|
|
this.dragOverBlockId = null;
|
|
|
|
this.dragOverPosition = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Remove previous drag-over classes
|
|
|
|
if (this.dragOverBlockId) {
|
|
|
|
const prevWrapper = this.editorContentRef.querySelector(`[data-block-id="${this.dragOverBlockId}"]`);
|
|
|
|
if (prevWrapper) {
|
|
|
|
prevWrapper.classList.remove('drag-over-before', 'drag-over-after');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-23 18:02:40 +00:00
|
|
|
this.dragOverBlockId = block.id;
|
|
|
|
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
// Add new drag-over class
|
|
|
|
const wrapper = e.currentTarget as HTMLElement;
|
|
|
|
wrapper.classList.add(`drag-over-${this.dragOverPosition}`);
|
2025-06-23 18:02:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private handleDragLeave(block: IBlock): void {
|
|
|
|
if (this.dragOverBlockId === block.id) {
|
2025-06-24 11:06:02 +00:00
|
|
|
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
|
|
|
if (wrapper) {
|
|
|
|
wrapper.classList.remove('drag-over-before', 'drag-over-after');
|
|
|
|
}
|
|
|
|
|
2025-06-23 18:02:40 +00:00
|
|
|
this.dragOverBlockId = null;
|
|
|
|
this.dragOverPosition = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Re-render blocks programmatically to reflect the new order
|
|
|
|
this.renderBlocksProgrammatically();
|
|
|
|
|
2025-06-23 18:02:40 +00:00
|
|
|
// 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);
|
|
|
|
}
|
2025-06-23 21:15:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
private handleTextSelection(e: MouseEvent): void {
|
2025-06-24 10:45:06 +00:00
|
|
|
// Don't interfere with slash menu
|
|
|
|
if (this.slashMenu.visible) return;
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Let the block component handle selection via custom event
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private handleSelectionChange(): void {
|
|
|
|
// Try to get selection from shadow root first, then fall back to window
|
|
|
|
const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null;
|
|
|
|
const windowSelection = window.getSelection();
|
|
|
|
const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement;
|
|
|
|
|
|
|
|
// Check both shadow and window selections
|
|
|
|
let selection = shadowSelection;
|
|
|
|
let selectedText = shadowSelection?.toString() || '';
|
|
|
|
|
|
|
|
// If no shadow selection, check window selection
|
|
|
|
if (!selectedText && windowSelection) {
|
|
|
|
selection = windowSelection;
|
|
|
|
selectedText = windowSelection.toString() || '';
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log('Selection change:', {
|
|
|
|
hasText: selectedText.length > 0,
|
|
|
|
selectedText: selectedText.substring(0, 50),
|
|
|
|
shadowSelection: !!shadowSelection,
|
|
|
|
windowSelection: !!windowSelection,
|
|
|
|
rangeCount: selection?.rangeCount,
|
|
|
|
editorContent: !!editorContent
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!selection || selection.rangeCount === 0 || !editorContent) {
|
|
|
|
console.log('No selection or editor content');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we have selected text, show the formatting menu
|
|
|
|
if (selectedText.length > 0) {
|
|
|
|
console.log('✅ Text selected:', selectedText);
|
|
|
|
|
|
|
|
if (selectedText !== this.selectedText) {
|
|
|
|
this.selectedText = selectedText;
|
|
|
|
this.updateFormattingMenuPosition();
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
} else if (this.formattingMenu.visible) {
|
2025-06-23 21:15:04 +00:00
|
|
|
console.log('No text selected, hiding menu');
|
|
|
|
this.hideFormattingMenu();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private updateFormattingMenuPosition(): void {
|
|
|
|
console.log('updateFormattingMenuPosition called');
|
|
|
|
const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot);
|
|
|
|
console.log('Selection coordinates:', coords);
|
|
|
|
|
|
|
|
if (coords) {
|
|
|
|
const container = this.shadowRoot!.querySelector('.wysiwyg-container');
|
|
|
|
if (!container) {
|
|
|
|
console.error('Container not found!');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const containerRect = container.getBoundingClientRect();
|
2025-06-24 10:45:06 +00:00
|
|
|
const formattingMenuPosition = {
|
2025-06-23 21:15:04 +00:00
|
|
|
x: coords.x - containerRect.left,
|
|
|
|
y: coords.y - containerRect.top
|
|
|
|
};
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
console.log('Setting menu position:', formattingMenuPosition);
|
|
|
|
// Show the global formatting menu
|
|
|
|
this.formattingMenu.show(
|
|
|
|
{ x: coords.x, y: coords.y }, // Use absolute coordinates
|
|
|
|
async (command: string) => await this.applyFormat(command)
|
|
|
|
);
|
2025-06-23 21:15:04 +00:00
|
|
|
} else {
|
|
|
|
console.log('No coordinates found');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private hideFormattingMenu(): void {
|
2025-06-24 10:45:06 +00:00
|
|
|
this.formattingMenu.hide();
|
2025-06-23 21:15:04 +00:00
|
|
|
this.selectedText = '';
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
public async applyFormat(command: string): Promise<void> {
|
2025-06-23 21:15:04 +00:00
|
|
|
// Save current selection before applying format
|
|
|
|
const selection = window.getSelection();
|
|
|
|
if (!selection || selection.rangeCount === 0) return;
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Get the current block
|
2025-06-23 21:15:04 +00:00
|
|
|
const anchorNode = selection.anchorNode;
|
|
|
|
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
|
|
|
|
? anchorNode.parentElement?.closest('.block')
|
|
|
|
: (anchorNode as Element)?.closest('.block');
|
|
|
|
|
|
|
|
if (!blockElement) return;
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
const blockWrapper = blockElement.closest('.block-wrapper');
|
|
|
|
const blockId = blockWrapper?.getAttribute('data-block-id');
|
2025-06-23 21:15:04 +00:00
|
|
|
const block = this.blocks.find(b => b.id === blockId);
|
2025-06-24 10:45:06 +00:00
|
|
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
if (!block || !blockComponent) return;
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Handle link command specially
|
|
|
|
if (command === 'link') {
|
|
|
|
const url = await this.showLinkDialog();
|
|
|
|
if (!url) {
|
|
|
|
// User cancelled - restore focus to block
|
|
|
|
blockComponent.focus();
|
|
|
|
return;
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
WysiwygFormatting.applyFormat(command, url);
|
|
|
|
} else {
|
|
|
|
// Apply the format
|
|
|
|
WysiwygFormatting.applyFormat(command);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update content after a microtask to ensure DOM is updated
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
|
|
|
|
// Force content update
|
|
|
|
block.content = blockComponent.getContent();
|
|
|
|
|
|
|
|
// Update value to persist changes
|
|
|
|
this.updateValue();
|
|
|
|
|
|
|
|
// For link command, close the formatting menu
|
|
|
|
if (command === 'link') {
|
|
|
|
this.hideFormattingMenu();
|
|
|
|
} else if (this.formattingMenu.visible) {
|
|
|
|
// Update menu position if still showing
|
|
|
|
this.updateFormattingMenuPosition();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure block still has focus
|
|
|
|
if (document.activeElement !== blockElement) {
|
|
|
|
blockComponent.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async showLinkDialog(): Promise<string | null> {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
let linkUrl: string | null = null;
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
// Focus the input after modal is shown
|
|
|
|
setTimeout(() => {
|
|
|
|
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
|
|
|
|
if (input) {
|
|
|
|
input.focus();
|
|
|
|
}
|
|
|
|
}, 100);
|
|
|
|
});
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
2025-06-23 21:28:58 +00:00
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
// Modal methods moved to WysiwygModalManager
|
2025-06-23 21:28:58 +00:00
|
|
|
private async showLanguageSelectionModal(): Promise<string | null> {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
let selectedLanguage: string | null = null;
|
|
|
|
|
|
|
|
DeesModal.createAndShow({
|
|
|
|
heading: 'Select Programming Language',
|
|
|
|
content: html`
|
|
|
|
<style>
|
|
|
|
.language-grid {
|
|
|
|
display: grid;
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
gap: 8px;
|
|
|
|
padding: 16px;
|
|
|
|
}
|
|
|
|
.language-button {
|
|
|
|
padding: 12px;
|
|
|
|
background: var(--dees-color-box);
|
|
|
|
border: 1px solid var(--dees-color-line-bright);
|
|
|
|
border-radius: 4px;
|
|
|
|
cursor: pointer;
|
|
|
|
text-align: center;
|
|
|
|
transition: all 0.2s;
|
|
|
|
}
|
|
|
|
.language-button:hover {
|
|
|
|
background: var(--dees-color-box-highlight);
|
|
|
|
border-color: var(--dees-color-primary);
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
<div class="language-grid">
|
|
|
|
${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html`
|
|
|
|
<div class="language-button" @click="${(e: MouseEvent) => {
|
|
|
|
selectedLanguage = lang.toLowerCase();
|
|
|
|
// Find and click the hidden OK button to close the modal
|
|
|
|
const modal = (e.target as HTMLElement).closest('dees-modal');
|
|
|
|
if (modal) {
|
|
|
|
const okButton = modal.shadowRoot?.querySelector('.bottomButton.ok') as HTMLElement;
|
|
|
|
if (okButton) okButton.click();
|
|
|
|
}
|
|
|
|
}}">${lang}</div>
|
|
|
|
`)}
|
|
|
|
</div>
|
|
|
|
`,
|
|
|
|
menuOptions: [
|
|
|
|
{
|
|
|
|
name: 'Cancel',
|
|
|
|
action: async (modal) => {
|
|
|
|
modal.destroy();
|
|
|
|
resolve(null);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: 'OK',
|
|
|
|
action: async (modal) => {
|
|
|
|
modal.destroy();
|
|
|
|
resolve(selectedLanguage);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private async showBlockSettingsModal(block: IBlock): Promise<void> {
|
|
|
|
let content: TemplateResult;
|
|
|
|
|
|
|
|
if (block.type === 'code') {
|
|
|
|
const currentLanguage = block.metadata?.language || 'plain text';
|
|
|
|
content = html`
|
|
|
|
<style>
|
|
|
|
.settings-section {
|
|
|
|
margin-bottom: 16px;
|
|
|
|
}
|
|
|
|
.settings-label {
|
|
|
|
font-weight: 500;
|
|
|
|
margin-bottom: 8px;
|
|
|
|
}
|
|
|
|
.language-grid {
|
|
|
|
display: grid;
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
gap: 8px;
|
|
|
|
}
|
|
|
|
.language-button {
|
|
|
|
padding: 8px;
|
|
|
|
background: var(--dees-color-box);
|
|
|
|
border: 1px solid var(--dees-color-line-bright);
|
|
|
|
border-radius: 4px;
|
|
|
|
cursor: pointer;
|
|
|
|
text-align: center;
|
|
|
|
transition: all 0.2s;
|
|
|
|
}
|
|
|
|
.language-button:hover {
|
|
|
|
background: var(--dees-color-box-highlight);
|
|
|
|
border-color: var(--dees-color-primary);
|
|
|
|
}
|
|
|
|
.language-button.selected {
|
|
|
|
background: var(--dees-color-primary);
|
|
|
|
color: white;
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
<div class="settings-section">
|
|
|
|
<div class="settings-label">Programming Language</div>
|
|
|
|
<div class="language-grid">
|
|
|
|
${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html`
|
|
|
|
<div class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
|
|
|
@click="${(e: MouseEvent) => {
|
|
|
|
if (!block.metadata) block.metadata = {};
|
|
|
|
block.metadata.language = lang.toLowerCase();
|
|
|
|
this.updateValue();
|
|
|
|
this.requestUpdate();
|
|
|
|
// Find and click the close button
|
|
|
|
const modal = (e.target as HTMLElement).closest('dees-modal');
|
|
|
|
if (modal) {
|
|
|
|
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
|
|
|
|
if (closeButton) closeButton.click();
|
|
|
|
}
|
|
|
|
}}">${lang}</div>
|
|
|
|
`)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
} else {
|
|
|
|
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
|
|
|
|
}
|
|
|
|
|
|
|
|
DeesModal.createAndShow({
|
|
|
|
heading: 'Block Settings',
|
|
|
|
content,
|
|
|
|
menuOptions: [
|
|
|
|
{
|
|
|
|
name: 'Close',
|
|
|
|
action: async (modal) => {
|
|
|
|
modal.destroy();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
}
|
2025-06-23 17:36:39 +00:00
|
|
|
}
|