fix(dees-modal): theming
This commit is contained in:
178
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal file
178
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { WysiwygFormatting } from './wysiwyg.formatting.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-formatting-menu': DeesFormattingMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-formatting-menu')
|
||||
export class DeesFormattingMenu extends DeesElement {
|
||||
private static instance: DeesFormattingMenu;
|
||||
|
||||
public static getInstance(): DeesFormattingMenu {
|
||||
if (!DeesFormattingMenu.instance) {
|
||||
DeesFormattingMenu.instance = new DeesFormattingMenu();
|
||||
document.body.appendChild(DeesFormattingMenu.instance);
|
||||
}
|
||||
return DeesFormattingMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
public visible: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
private callback: ((command: string) => void | Promise<void>) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.formatting-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.format-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.format-button.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-button.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-button.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-button .code-icon {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="formatting-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
@mousedown="${(e: MouseEvent) => {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
@focus="${(e: FocusEvent) => {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
>
|
||||
${WysiwygFormatting.formatButtons.map(button => html`
|
||||
<button
|
||||
class="format-button ${button.command}"
|
||||
@click="${() => this.applyFormat(button.command)}"
|
||||
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||
>
|
||||
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private applyFormat(command: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(command);
|
||||
}
|
||||
// Don't hide menu after applying format (except for link)
|
||||
if (command === 'link') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
public updatePosition(position: { x: number; y: number }): void {
|
||||
this.position = position;
|
||||
}
|
||||
}
|
@ -24,7 +24,9 @@ import {
|
||||
WysiwygInputHandler,
|
||||
WysiwygKeyboardHandler,
|
||||
WysiwygDragDropHandler,
|
||||
WysiwygModalManager
|
||||
WysiwygModalManager,
|
||||
DeesSlashMenu,
|
||||
DeesFormattingMenu
|
||||
} from './index.js';
|
||||
|
||||
declare global {
|
||||
@ -55,17 +57,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
@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;
|
||||
// Slash menu is now globally rendered
|
||||
private slashMenu = DeesSlashMenu.getInstance();
|
||||
|
||||
@state()
|
||||
private draggedBlockId: string | null = null;
|
||||
@ -76,11 +69,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
@state()
|
||||
private dragOverPosition: 'before' | 'after' | null = null;
|
||||
|
||||
@state()
|
||||
private showFormattingMenu: boolean = false;
|
||||
|
||||
@state()
|
||||
private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
||||
// Formatting menu is now globally rendered
|
||||
private formattingMenu = DeesFormattingMenu.getInstance();
|
||||
|
||||
@state()
|
||||
private selectedText: string = '';
|
||||
@ -129,8 +119,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
// Add global selection listener
|
||||
console.log('Adding selectionchange listener');
|
||||
document.addEventListener('selectionchange', this.selectionChangeHandler);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<dees-label
|
||||
@ -145,8 +144,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
>
|
||||
${this.blocks.map(block => this.renderBlock(block))}
|
||||
</div>
|
||||
${this.showSlashMenu ? this.renderSlashMenu() : ''}
|
||||
${this.showFormattingMenu ? this.renderFormattingMenu() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -173,15 +170,19 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
|
||||
></div>
|
||||
` : ''}
|
||||
${WysiwygBlocks.renderBlock(block, isSelected, {
|
||||
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),
|
||||
})}
|
||||
<dees-wysiwyg-block
|
||||
.block="${block}"
|
||||
.isSelected="${isSelected}"
|
||||
.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),
|
||||
}}"
|
||||
></dees-wysiwyg-block>
|
||||
${block.type !== 'divider' ? html`
|
||||
<div
|
||||
class="block-settings"
|
||||
@ -205,311 +206,56 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
`;
|
||||
}
|
||||
|
||||
public 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 renderFormattingMenu(): TemplateResult {
|
||||
return WysiwygFormatting.renderFormattingMenu(
|
||||
this.formattingMenuPosition,
|
||||
(command) => this.applyFormat(command)
|
||||
);
|
||||
}
|
||||
|
||||
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 if (block.type === 'code') {
|
||||
// For code blocks, preserve the exact text content
|
||||
block.content = target.textContent || '';
|
||||
} else {
|
||||
// For other blocks, preserve HTML formatting
|
||||
block.content = target.innerHTML || '';
|
||||
}
|
||||
|
||||
// Check for block type change intents (use text content for detection, not HTML)
|
||||
const textContent = target.textContent || '';
|
||||
const detectedType = this.detectBlockTypeIntent(textContent);
|
||||
|
||||
// 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 newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(block, newBlock);
|
||||
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
return;
|
||||
} else if (detectedType.type === 'code') {
|
||||
// For code blocks, ask for language
|
||||
WysiwygModalManager.showLanguageSelectionModal().then(language => {
|
||||
if (language) {
|
||||
block.type = 'code';
|
||||
block.content = '';
|
||||
block.metadata = { language };
|
||||
|
||||
// Clear the DOM element immediately
|
||||
target.textContent = '';
|
||||
|
||||
// Force update
|
||||
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 (textContent === '/' || (textContent.startsWith('/') && this.showSlashMenu)) {
|
||||
// Only show menu on initial '/', or update filter if already showing
|
||||
if (!this.showSlashMenu && textContent === '/') {
|
||||
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 = textContent.slice(1);
|
||||
} else if (!textContent.startsWith('/')) {
|
||||
this.closeSlashMenu();
|
||||
}
|
||||
|
||||
// Don't update value on every input - let the browser handle typing normally
|
||||
// But schedule a save after a delay
|
||||
// Removed - now handled by inputHandler
|
||||
}
|
||||
|
||||
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
|
||||
if (this.showSlashMenu && ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key)) {
|
||||
this.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle formatting shortcuts
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
this.applyFormat('bold');
|
||||
return;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
this.applyFormat('italic');
|
||||
return;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
this.applyFormat('underline');
|
||||
return;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
this.applyFormat('link');
|
||||
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') {
|
||||
// Handle code blocks specially
|
||||
if (block.type === 'code') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter in code blocks creates a new block
|
||||
e.preventDefault();
|
||||
const newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// For normal Enter in code blocks, let the browser handle it (creates new line)
|
||||
return;
|
||||
}
|
||||
|
||||
// For other block types, handle Enter normally (without shift)
|
||||
if (!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 newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Otherwise, let the browser handle creating new list items
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
} 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;
|
||||
this.slashMenu.navigate('down');
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.slashMenuSelectedIndex = this.slashMenuSelectedIndex === 0
|
||||
? menuItems.length - 1
|
||||
: this.slashMenuSelectedIndex - 1;
|
||||
this.slashMenu.navigate('up');
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (menuItems[this.slashMenuSelectedIndex]) {
|
||||
this.insertBlock(menuItems[this.slashMenuSelectedIndex].type as IBlock['type']);
|
||||
}
|
||||
this.slashMenu.selectCurrent();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.closeSlashMenu();
|
||||
this.closeSlashMenu(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public closeSlashMenu() {
|
||||
if (this.showSlashMenu && this.selectedBlockId) {
|
||||
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}"]`);
|
||||
if (wrapperElement) {
|
||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
||||
if (blockElement && (blockElement.textContent || '').startsWith('/')) {
|
||||
// Clear the slash command text
|
||||
blockElement.textContent = '';
|
||||
currentBlock.content = '';
|
||||
// Ensure cursor stays in the block
|
||||
blockElement.focus();
|
||||
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.showSlashMenu = false;
|
||||
this.slashMenuFilter = '';
|
||||
this.slashMenuSelectedIndex = 0;
|
||||
this.slashMenu.hide();
|
||||
}
|
||||
|
||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
||||
@ -552,33 +298,31 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
|
||||
private handleBlockBlur(block: IBlock) {
|
||||
// Don't update value if slash menu is visible
|
||||
if (this.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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];
|
||||
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);
|
||||
}
|
||||
if (lastBlock.type !== 'divider') {
|
||||
this.blockOperations.focusBlock(lastBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -592,79 +336,91 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
};
|
||||
}
|
||||
|
||||
private insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
|
||||
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)];
|
||||
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
if (focusNewBlock) {
|
||||
setTimeout(() => {
|
||||
const wrapperElement = this.shadowRoot!.querySelector(`[data-block-id="${newBlock.id}"]`);
|
||||
if (wrapperElement && newBlock.type !== 'divider') {
|
||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
blockElement.focus();
|
||||
WysiwygBlocks.setCursorToStart(blockElement);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
await this.blockOperations.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
public async insertBlock(type: IBlock['type']) {
|
||||
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
|
||||
const currentBlock = this.blocks[currentBlockIndex];
|
||||
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||
|
||||
if (currentBlock) {
|
||||
// If it's a code block, ask for language
|
||||
if (type === 'code') {
|
||||
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
||||
if (!language) {
|
||||
// User cancelled
|
||||
this.closeSlashMenu();
|
||||
return;
|
||||
}
|
||||
currentBlock.metadata = { language };
|
||||
}
|
||||
|
||||
currentBlock.type = type;
|
||||
currentBlock.content = '';
|
||||
|
||||
if (type === 'divider') {
|
||||
currentBlock.content = ' ';
|
||||
const newBlock = this.createNewBlock();
|
||||
this.insertBlockAfter(currentBlock, newBlock);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.closeSlashMenu();
|
||||
// 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
|
||||
}
|
||||
currentBlock.metadata = { language };
|
||||
}
|
||||
|
||||
// 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 || '';
|
||||
}
|
||||
|
||||
// Update and refocus
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
|
||||
// Wait for update to complete before focusing
|
||||
await this.updateComplete;
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
private updateValue() {
|
||||
@ -833,35 +589,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
|
||||
|
||||
private handleTextSelection(e: MouseEvent): void {
|
||||
// Stop event to prevent it from bubbling up
|
||||
e.stopPropagation();
|
||||
// Don't interfere with slash menu
|
||||
if (this.slashMenu.visible) return;
|
||||
|
||||
console.log('handleTextSelection called from mouseup on contenteditable');
|
||||
|
||||
// Small delay to ensure selection is complete
|
||||
setTimeout(() => {
|
||||
// Alternative approach: check selection directly within the target element
|
||||
const target = e.target as HTMLElement;
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const selectedText = selection.toString();
|
||||
console.log('Direct selection check in handleTextSelection:', {
|
||||
selectedText: selectedText.substring(0, 50),
|
||||
hasText: selectedText.length > 0,
|
||||
target: target.tagName + '.' + target.className
|
||||
});
|
||||
|
||||
if (selectedText.length > 0) {
|
||||
// We know this came from a mouseup on our contenteditable, so it's definitely our selection
|
||||
console.log('✅ Text selected via mouseup:', selectedText);
|
||||
this.selectedText = selectedText;
|
||||
this.updateFormattingMenuPosition();
|
||||
} else if (this.showFormattingMenu) {
|
||||
this.hideFormattingMenu();
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
// Let the block component handle selection via custom event
|
||||
}
|
||||
|
||||
private handleSelectionChange(): void {
|
||||
@ -902,7 +633,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
this.selectedText = selectedText;
|
||||
this.updateFormattingMenuPosition();
|
||||
}
|
||||
} else if (this.showFormattingMenu) {
|
||||
} else if (this.formattingMenu.visible) {
|
||||
console.log('No text selected, hiding menu');
|
||||
this.hideFormattingMenu();
|
||||
}
|
||||
@ -922,42 +653,33 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
this.formattingMenuPosition = {
|
||||
const formattingMenuPosition = {
|
||||
x: coords.x - containerRect.left,
|
||||
y: coords.y - containerRect.top
|
||||
};
|
||||
|
||||
console.log('Setting menu position:', this.formattingMenuPosition);
|
||||
this.showFormattingMenu = true;
|
||||
console.log('showFormattingMenu set to:', this.showFormattingMenu);
|
||||
|
||||
// Force update
|
||||
this.requestUpdate();
|
||||
|
||||
// Check if menu exists in DOM after update
|
||||
setTimeout(() => {
|
||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||
console.log('Menu in DOM after update:', menu);
|
||||
if (menu) {
|
||||
console.log('Menu style:', menu.getAttribute('style'));
|
||||
}
|
||||
}, 100);
|
||||
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)
|
||||
);
|
||||
} else {
|
||||
console.log('No coordinates found');
|
||||
}
|
||||
}
|
||||
|
||||
private hideFormattingMenu(): void {
|
||||
this.showFormattingMenu = false;
|
||||
this.formattingMenu.hide();
|
||||
this.selectedText = '';
|
||||
}
|
||||
|
||||
public applyFormat(command: string): void {
|
||||
public async applyFormat(command: string): Promise<void> {
|
||||
// Save current selection before applying format
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
// Get the current block to update its content
|
||||
// Get the current block
|
||||
const anchorNode = selection.anchorNode;
|
||||
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE
|
||||
? anchorNode.parentElement?.closest('.block')
|
||||
@ -965,31 +687,120 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
|
||||
if (!blockElement) return;
|
||||
|
||||
const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id');
|
||||
const blockWrapper = blockElement.closest('.block-wrapper');
|
||||
const blockId = blockWrapper?.getAttribute('data-block-id');
|
||||
const block = this.blocks.find(b => b.id === blockId);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (!block) return;
|
||||
if (!block || !blockComponent) return;
|
||||
|
||||
// Apply the format
|
||||
WysiwygFormatting.applyFormat(command);
|
||||
// Handle link command specially
|
||||
if (command === 'link') {
|
||||
const url = await this.showLinkDialog();
|
||||
if (!url) {
|
||||
// User cancelled - restore focus to block
|
||||
blockComponent.focus();
|
||||
return;
|
||||
}
|
||||
WysiwygFormatting.applyFormat(command, url);
|
||||
} else {
|
||||
// Apply the format
|
||||
WysiwygFormatting.applyFormat(command);
|
||||
}
|
||||
|
||||
// Update block content after format is applied
|
||||
setTimeout(() => {
|
||||
if (block.type === 'list') {
|
||||
const listItems = blockElement.querySelectorAll('li');
|
||||
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
||||
} else {
|
||||
// For other blocks, preserve HTML formatting
|
||||
block.content = blockElement.innerHTML;
|
||||
}
|
||||
// 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;
|
||||
|
||||
this.updateValue();
|
||||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Keep selection active
|
||||
if (command !== 'link') {
|
||||
this.updateFormattingMenuPosition();
|
||||
}
|
||||
}, 10);
|
||||
// Focus the input after modal is shown
|
||||
setTimeout(() => {
|
||||
const input = document.querySelector('dees-modal .link-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
private async showLanguageSelectionModal(): Promise<string | null> {
|
||||
|
209
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal file
209
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-slash-menu': DeesSlashMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-slash-menu')
|
||||
export class DeesSlashMenu extends DeesElement {
|
||||
private static instance: DeesSlashMenu;
|
||||
|
||||
public static getInstance(): DeesSlashMenu {
|
||||
if (!DeesSlashMenu.instance) {
|
||||
DeesSlashMenu.instance = new DeesSlashMenu();
|
||||
document.body.appendChild(DeesSlashMenu.instance);
|
||||
}
|
||||
return DeesSlashMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
public visible: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
@state()
|
||||
private filter: string = '';
|
||||
|
||||
@state()
|
||||
private selectedIndex: number = 0;
|
||||
|
||||
private callback: ((type: string) => void) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
const menuItems = this.getFilteredMenuItems();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="slash-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
@mousedown="${(e: MouseEvent) => {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
@focus="${(e: FocusEvent) => {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}"
|
||||
>
|
||||
${menuItems.map((item, index) => html`
|
||||
<div
|
||||
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
|
||||
@click="${() => this.selectItem(item.type)}"
|
||||
@mouseenter="${() => this.selectedIndex = index}"
|
||||
>
|
||||
<span class="icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
||||
return allItems.filter(item =>
|
||||
this.filter === '' ||
|
||||
item.label.toLowerCase().includes(this.filter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
private selectItem(type: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(type);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
public updateFilter(filter: string): void {
|
||||
this.filter = filter;
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
public navigate(direction: 'up' | 'down'): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (direction === 'down') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % items.length;
|
||||
} else {
|
||||
this.selectedIndex = this.selectedIndex === 0
|
||||
? items.length - 1
|
||||
: this.selectedIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
public selectCurrent(): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (items[this.selectedIndex]) {
|
||||
this.selectItem(items[this.selectedIndex].type);
|
||||
}
|
||||
}
|
||||
}
|
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
@ -0,0 +1,551 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
query,
|
||||
unsafeStatic,
|
||||
static as staticHtml,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-wysiwyg-block': DeesWysiwygBlock;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-wysiwyg-block')
|
||||
export class DeesWysiwygBlock extends DeesElement {
|
||||
@property({ type: Object })
|
||||
public block: IBlock;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public isSelected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
public handlers: {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
};
|
||||
|
||||
@query('.block')
|
||||
private blockElement: HTMLDivElement;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 4px 0;
|
||||
min-height: 1.6em;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
}
|
||||
|
||||
.block:empty:not(:focus)::before {
|
||||
content: attr(data-placeholder);
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 24px 0 8px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 20px 0 6px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 16px 0 4px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.quote {
|
||||
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
padding-left: 20px;
|
||||
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.block.code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
|
||||
padding: 16px 20px;
|
||||
padding-top: 32px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.block.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.block.divider {
|
||||
padding: 0;
|
||||
margin: 16px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Formatting styles */
|
||||
.block :is(b, strong) {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block :is(i, em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.block u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.block s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.block code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
|
||||
}
|
||||
|
||||
.block a {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.block a:hover {
|
||||
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.code-language {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
|
||||
color: ${cssManager.bdTheme('#586069', '#8b949e')};
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 0 6px 0 6px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
text-transform: lowercase;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Selection styles */
|
||||
.block ::selection {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Paragraph specific styles */
|
||||
.block.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Strike through */
|
||||
.block :is(s, strike) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* List specific margin adjustments */
|
||||
.block.list li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Block margin adjustments based on type */
|
||||
:host-context(.block-wrapper:first-child) .block {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
:host-context(.block-wrapper:last-child) .block {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Selected state */
|
||||
.block.selected {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
|
||||
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
|
||||
border-radius: 4px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
protected shouldUpdate(): boolean {
|
||||
// Only update if the block type or id changes
|
||||
// Content changes are handled directly in the DOM
|
||||
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.block) return html``;
|
||||
|
||||
if (this.block.type === 'divider') {
|
||||
return html`
|
||||
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.block.type === 'code') {
|
||||
const language = this.block.metadata?.language || 'plain text';
|
||||
return html`
|
||||
<div class="code-block-container">
|
||||
<div class="code-language">${language}</div>
|
||||
<div
|
||||
class="block code ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-block-type="${this.block.type}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
.textContent="${this.block.content || ''}"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const placeholder = this.getPlaceholder();
|
||||
const initialContent = this.getInitialContent();
|
||||
|
||||
return staticHtml`
|
||||
<div
|
||||
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
>${unsafeStatic(initialContent)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getPlaceholder(): string {
|
||||
switch (this.block.type) {
|
||||
case 'paragraph':
|
||||
return "Type '/' for commands...";
|
||||
case 'heading-1':
|
||||
return 'Heading 1';
|
||||
case 'heading-2':
|
||||
return 'Heading 2';
|
||||
case 'heading-3':
|
||||
return 'Heading 3';
|
||||
case 'quote':
|
||||
return 'Quote';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getInitialContent(): string {
|
||||
if (this.block.type === 'list') {
|
||||
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
||||
}
|
||||
return this.block.content || '';
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!this.blockElement.hasAttribute('contenteditable')) {
|
||||
this.blockElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
this.blockElement.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== this.blockElement) {
|
||||
Promise.resolve().then(() => {
|
||||
this.blockElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!this.blockElement.hasAttribute('contenteditable')) {
|
||||
this.blockElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// Focus the element
|
||||
this.blockElement.focus();
|
||||
|
||||
// Set cursor position after focus is established
|
||||
const setCursor = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart();
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd();
|
||||
} else if (typeof position === 'number') {
|
||||
// Set cursor at specific position
|
||||
const range = document.createRange();
|
||||
const textNode = this.getFirstTextNode(this.blockElement);
|
||||
|
||||
if (textNode) {
|
||||
const length = textNode.textContent?.length || 0;
|
||||
const safePosition = Math.min(position, length);
|
||||
range.setStart(textNode, safePosition);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else if (this.blockElement.childNodes.length === 0) {
|
||||
// Empty block - create a text node
|
||||
const emptyText = document.createTextNode('');
|
||||
this.blockElement.appendChild(emptyText);
|
||||
range.setStart(emptyText, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === this.blockElement) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === this.blockElement) {
|
||||
setCursor();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getFirstTextNode(node: Node): Text | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node as Text;
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const textNode = this.getFirstTextNode(node.childNodes[i]);
|
||||
if (textNode) return textNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public getContent(): string {
|
||||
if (!this.blockElement) return '';
|
||||
|
||||
if (this.block.type === 'list') {
|
||||
const listItems = this.blockElement.querySelectorAll('li');
|
||||
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||
} else if (this.block.type === 'code') {
|
||||
return this.blockElement.textContent || '';
|
||||
} else {
|
||||
return this.blockElement.innerHTML || '';
|
||||
}
|
||||
}
|
||||
|
||||
public setContent(content: string): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === this.blockElement;
|
||||
|
||||
if (this.block.type === 'list') {
|
||||
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
|
||||
} else if (this.block.type === 'code') {
|
||||
this.blockElement.textContent = content;
|
||||
} else {
|
||||
this.blockElement.innerHTML = content;
|
||||
}
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
this.blockElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public setCursorToStart(): void {
|
||||
WysiwygBlocks.setCursorToStart(this.blockElement);
|
||||
}
|
||||
|
||||
public setCursorToEnd(): void {
|
||||
WysiwygBlocks.setCursorToEnd(this.blockElement);
|
||||
}
|
||||
|
||||
public focusListItem(): void {
|
||||
if (this.block.type === 'list') {
|
||||
WysiwygBlocks.focusListItem(this.blockElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets content split at cursor position
|
||||
*/
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
if (!this.blockElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return {
|
||||
before: this.getContent(),
|
||||
after: ''
|
||||
};
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if selection is within this block
|
||||
if (!this.blockElement.contains(range.commonAncestorContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clone the range to extract content before and after cursor
|
||||
const beforeRange = range.cloneRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
const afterRange = range.cloneRange();
|
||||
afterRange.selectNodeContents(this.blockElement);
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
|
||||
// Extract content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(beforeFragment);
|
||||
const beforeHtml = tempDiv.innerHTML;
|
||||
|
||||
tempDiv.innerHTML = '';
|
||||
tempDiv.appendChild(afterFragment);
|
||||
const afterHtml = tempDiv.innerHTML;
|
||||
|
||||
return {
|
||||
before: beforeHtml,
|
||||
after: afterHtml
|
||||
};
|
||||
}
|
||||
|
||||
private handleMouseUp(_e: MouseEvent): void {
|
||||
// Check if we have a selection within this block
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if selection is within this block
|
||||
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) {
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length > 0) {
|
||||
// Dispatch a custom event that can cross shadow DOM boundaries
|
||||
this.dispatchEvent(new CustomEvent('block-text-selected', {
|
||||
detail: {
|
||||
text: selectedText,
|
||||
blockId: this.block.id,
|
||||
range: range
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export * from './wysiwyg.types.js';
|
||||
export * from './wysiwyg.interfaces.js';
|
||||
export * from './wysiwyg.styles.js';
|
||||
export * from './wysiwyg.converters.js';
|
||||
export * from './wysiwyg.shortcuts.js';
|
||||
@ -8,4 +9,7 @@ export * from './wysiwyg.blockoperations.js';
|
||||
export * from './wysiwyg.inputhandler.js';
|
||||
export * from './wysiwyg.keyboardhandler.js';
|
||||
export * from './wysiwyg.dragdrophandler.js';
|
||||
export * from './wysiwyg.modalmanager.js';
|
||||
export * from './wysiwyg.modalmanager.js';
|
||||
export * from './dees-wysiwyg-block.js';
|
||||
export * from './dees-slash-menu.js';
|
||||
export * from './dees-formatting-menu.js';
|
@ -24,7 +24,7 @@ export class WysiwygBlockOperations {
|
||||
/**
|
||||
* Inserts a block after the specified block
|
||||
*/
|
||||
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
|
||||
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
|
||||
const blocks = this.component.blocks;
|
||||
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
||||
|
||||
@ -35,11 +35,14 @@ export class WysiwygBlockOperations {
|
||||
];
|
||||
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
setTimeout(() => {
|
||||
this.focusBlock(newBlock.id);
|
||||
}, 50);
|
||||
// Wait for the component to update
|
||||
await this.component.updateComplete;
|
||||
|
||||
// Focus the new block
|
||||
await this.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,17 +71,19 @@ export class WysiwygBlockOperations {
|
||||
/**
|
||||
* Focuses a specific block
|
||||
*/
|
||||
focusBlock(blockId: string, cursorPosition: 'start' | 'end' = 'start'): void {
|
||||
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
|
||||
// First ensure the component is updated
|
||||
await this.component.updateComplete;
|
||||
|
||||
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (wrapperElement) {
|
||||
const blockElement = wrapperElement.querySelector('.block') as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
blockElement.focus();
|
||||
if (cursorPosition === 'start') {
|
||||
WysiwygBlocks.setCursorToStart(blockElement);
|
||||
} else {
|
||||
WysiwygBlocks.setCursorToEnd(blockElement);
|
||||
}
|
||||
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
// Wait a frame to ensure the block is rendered
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Now focus with cursor position
|
||||
blockComponent.focusWithCursor(cursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ export class WysiwygBlocks {
|
||||
const items = content.split('\n').filter(item => item.trim());
|
||||
if (items.length === 0) return '';
|
||||
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
return `<${listTag}>${items.map(item => `<li>${WysiwygConverters.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
|
||||
// Don't escape HTML to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
|
||||
static renderBlock(
|
||||
@ -102,21 +103,88 @@ export class WysiwygBlocks {
|
||||
}
|
||||
|
||||
static setCursorToEnd(element: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element - add a zero-width space to enable cursor
|
||||
const textNode = document.createTextNode('\u200B');
|
||||
element.appendChild(textNode);
|
||||
range.setStart(textNode, 1);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the last text node or element
|
||||
const lastNode = this.getLastNode(element);
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.setStartAfter(lastNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
// Remove zero-width space if it was added
|
||||
if (element.textContent === '\u200B') {
|
||||
element.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
static setCursorToStart(element: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(true);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element
|
||||
range.setStart(element, 0);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the first text node or element
|
||||
const firstNode = this.getFirstNode(element);
|
||||
if (firstNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(firstNode, 0);
|
||||
} else {
|
||||
range.setStartBefore(firstNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
private static getLastNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const lastChild = element.childNodes[element.childNodes.length - 1];
|
||||
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
|
||||
return lastChild;
|
||||
}
|
||||
|
||||
return this.getLastNode(lastChild);
|
||||
}
|
||||
|
||||
private static getFirstNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const firstChild = element.childNodes[0];
|
||||
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
|
||||
return firstChild;
|
||||
}
|
||||
|
||||
return this.getFirstNode(firstChild);
|
||||
}
|
||||
|
||||
static focusListItem(listElement: HTMLElement): void {
|
||||
|
@ -31,7 +31,8 @@ export class WysiwygConverters {
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (items.length > 0) {
|
||||
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
return `<${listTag}>${items.map(item => `<li>${this.escapeHtml(item)}</li>`).join('')}</${listTag}>`;
|
||||
// Don't escape HTML in list items to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
return '';
|
||||
case 'divider':
|
||||
@ -135,7 +136,8 @@ export class WysiwygConverters {
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
const listItems = Array.from(element.querySelectorAll('li'));
|
||||
const content = listItems.map(li => li.textContent || '').join('\n');
|
||||
// Use innerHTML to preserve formatting
|
||||
const content = listItems.map(li => li.innerHTML || '').join('\n');
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
|
@ -42,46 +42,104 @@ export class WysiwygFormatting {
|
||||
`;
|
||||
}
|
||||
|
||||
static applyFormat(command: string, value?: string): void {
|
||||
static applyFormat(command: string, value?: string): boolean {
|
||||
// Save current selection
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Apply format based on command
|
||||
switch (command) {
|
||||
case 'bold':
|
||||
this.wrapSelection(range, 'strong');
|
||||
break;
|
||||
|
||||
case 'italic':
|
||||
this.wrapSelection(range, 'em');
|
||||
break;
|
||||
|
||||
case 'underline':
|
||||
this.wrapSelection(range, 'u');
|
||||
break;
|
||||
|
||||
case 'strikeThrough':
|
||||
document.execCommand(command, false);
|
||||
this.wrapSelection(range, 's');
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
// For inline code, wrap selection in <code> tags
|
||||
const codeElement = document.createElement('code');
|
||||
try {
|
||||
codeElement.appendChild(range.extractContents());
|
||||
range.insertNode(codeElement);
|
||||
|
||||
// Select the newly created code element
|
||||
range.selectNodeContents(codeElement);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
// Fallback to execCommand if range manipulation fails
|
||||
document.execCommand('fontName', false, 'monospace');
|
||||
}
|
||||
this.wrapSelection(range, 'code');
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
const url = value || prompt('Enter URL:');
|
||||
if (url) {
|
||||
document.execCommand('createLink', false, url);
|
||||
// Don't use prompt - return false to indicate we need async input
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
this.wrapSelectionWithLink(range, value);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static wrapSelection(range: Range, tagName: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Check if we're already wrapped in this tag
|
||||
const parentElement = range.commonAncestorContainer.parentElement;
|
||||
if (parentElement && parentElement.tagName.toLowerCase() === tagName) {
|
||||
// Unwrap
|
||||
const parent = parentElement.parentNode;
|
||||
while (parentElement.firstChild) {
|
||||
parent?.insertBefore(parentElement.firstChild, parentElement);
|
||||
}
|
||||
parent?.removeChild(parentElement);
|
||||
|
||||
// Restore selection
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// Wrap selection
|
||||
const wrapper = document.createElement(tagName);
|
||||
try {
|
||||
// Extract and wrap contents
|
||||
const contents = range.extractContents();
|
||||
wrapper.appendChild(contents);
|
||||
range.insertNode(wrapper);
|
||||
|
||||
// Select the wrapped content
|
||||
range.selectNodeContents(wrapper);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to wrap selection:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static wrapSelectionWithLink(range: Range, url: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
try {
|
||||
const contents = range.extractContents();
|
||||
link.appendChild(contents);
|
||||
range.insertNode(link);
|
||||
|
||||
// Select the link
|
||||
range.selectNodeContents(link);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to create link:', e);
|
||||
}
|
||||
}
|
||||
|
||||
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
|
||||
@ -118,10 +176,33 @@ export class WysiwygFormatting {
|
||||
}
|
||||
|
||||
static isFormattingApplied(command: string): boolean {
|
||||
try {
|
||||
return document.queryCommandState(command);
|
||||
} catch {
|
||||
return false;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const container = range.commonAncestorContainer;
|
||||
const element = container.nodeType === Node.TEXT_NODE
|
||||
? container.parentElement
|
||||
: container as Element;
|
||||
|
||||
if (!element) return false;
|
||||
|
||||
// Check if formatting is applied by looking at parent elements
|
||||
switch (command) {
|
||||
case 'bold':
|
||||
return !!element.closest('b, strong');
|
||||
case 'italic':
|
||||
return !!element.closest('i, em');
|
||||
case 'underline':
|
||||
return !!element.closest('u');
|
||||
case 'strikeThrough':
|
||||
return !!element.closest('s, strike');
|
||||
case 'code':
|
||||
return !!element.closest('code');
|
||||
case 'link':
|
||||
return !!element.closest('a');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,20 +42,41 @@ export class WysiwygInputHandler {
|
||||
* Updates block content based on its type
|
||||
*/
|
||||
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
||||
if (block.type === 'list') {
|
||||
const listItems = target.querySelectorAll('li');
|
||||
block.content = Array.from(listItems).map(li => li.textContent || '').join('\n');
|
||||
// Get the block component for proper content extraction
|
||||
const wrapperElement = target.closest('.block-wrapper');
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent) {
|
||||
// Use the block component's getContent method for consistency
|
||||
block.content = blockComponent.getContent();
|
||||
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
// Update list metadata if needed
|
||||
if (block.type === 'list') {
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (block.type === 'code') {
|
||||
block.content = target.textContent || '';
|
||||
} else {
|
||||
block.content = target.innerHTML || '';
|
||||
// Fallback if block component not found
|
||||
if (block.type === 'list') {
|
||||
const listItems = target.querySelectorAll('li');
|
||||
// Use innerHTML to preserve formatting
|
||||
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
} else if (block.type === 'code') {
|
||||
block.content = target.textContent || '';
|
||||
} else {
|
||||
block.content = target.innerHTML || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,24 +172,54 @@ export class WysiwygInputHandler {
|
||||
* Handles slash command detection and menu display
|
||||
*/
|
||||
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
||||
if (textContent === '/' || (textContent.startsWith('/') && this.component.showSlashMenu)) {
|
||||
if (!this.component.showSlashMenu && textContent === '/') {
|
||||
this.component.showSlashMenu = true;
|
||||
this.component.slashMenuSelectedIndex = 0;
|
||||
const slashMenu = this.component.slashMenu;
|
||||
const isSlashMenuVisible = slashMenu && slashMenu.visible;
|
||||
|
||||
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
|
||||
if (!isSlashMenuVisible && textContent === '/') {
|
||||
// Get position for menu based on cursor location
|
||||
const rect = this.getCaretCoordinates(target);
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const containerRect = this.component.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
|
||||
// Show the slash menu at the cursor position
|
||||
slashMenu.show(
|
||||
{ x: rect.left, y: rect.bottom + 4 },
|
||||
(type: string) => {
|
||||
this.component.insertBlock(type);
|
||||
}
|
||||
);
|
||||
|
||||
this.component.slashMenuPosition = {
|
||||
x: rect.left - containerRect.left,
|
||||
y: rect.bottom - containerRect.top + 4
|
||||
};
|
||||
// Ensure the block maintains focus
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement !== target) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Update filter
|
||||
if (slashMenu) {
|
||||
slashMenu.updateFilter(textContent.slice(1));
|
||||
}
|
||||
this.component.slashMenuFilter = textContent.slice(1);
|
||||
} else if (!textContent.startsWith('/')) {
|
||||
this.component.closeSlashMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the coordinates of the caret/cursor
|
||||
*/
|
||||
private getCaretCoordinates(element: HTMLElement): DOMRect {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to element position
|
||||
return element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules auto-save after a delay
|
||||
@ -177,6 +228,10 @@ export class WysiwygInputHandler {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
// Don't auto-save if slash menu is open
|
||||
if (this.component.slashMenu && this.component.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.component.updateValue();
|
||||
}, 1000);
|
||||
|
79
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal file
79
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { DeesSlashMenu } from './dees-slash-menu.js';
|
||||
import { DeesFormattingMenu } from './dees-formatting-menu.js';
|
||||
|
||||
/**
|
||||
* Interface for the main wysiwyg component
|
||||
*/
|
||||
export interface IWysiwygComponent {
|
||||
// State
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
|
||||
// Menus
|
||||
slashMenu: DeesSlashMenu;
|
||||
formattingMenu: DeesFormattingMenu;
|
||||
|
||||
// Methods
|
||||
updateValue(): void;
|
||||
requestUpdate(): Promise<void>;
|
||||
updateComplete: Promise<boolean>;
|
||||
insertBlock(type: string): Promise<void>;
|
||||
closeSlashMenu(clearSlash?: boolean): void;
|
||||
applyFormat(command: string): Promise<void>;
|
||||
handleSlashMenuKeyboard(e: KeyboardEvent): void;
|
||||
|
||||
// Handlers
|
||||
blockOperations: IBlockOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block operations
|
||||
*/
|
||||
export interface IBlockOperations {
|
||||
createBlock(type?: IBlock['type'], content?: string, metadata?: any): IBlock;
|
||||
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock?: boolean): Promise<void>;
|
||||
removeBlock(blockId: string): void;
|
||||
findBlock(blockId: string): IBlock | undefined;
|
||||
getBlockIndex(blockId: string): number;
|
||||
focusBlock(blockId: string, cursorPosition?: 'start' | 'end' | number): Promise<void>;
|
||||
updateBlockContent(blockId: string, content: string): void;
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void;
|
||||
moveBlock(blockId: string, targetIndex: number): void;
|
||||
getPreviousBlock(blockId: string): IBlock | null;
|
||||
getNextBlock(blockId: string): IBlock | null;
|
||||
splitBlock(blockId: string, splitPosition: number): Promise<IBlock>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block component
|
||||
*/
|
||||
export interface IWysiwygBlockComponent {
|
||||
block: IBlock;
|
||||
isSelected: boolean;
|
||||
blockElement: HTMLDivElement | null;
|
||||
|
||||
focus(): void;
|
||||
focusWithCursor(position: 'start' | 'end' | number): void;
|
||||
getContent(): string;
|
||||
setContent(content: string): void;
|
||||
setCursorToStart(): void;
|
||||
setCursorToEnd(): void;
|
||||
focusListItem(): void;
|
||||
getSplitContent(splitPosition: number): { before: string; after: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler interfaces
|
||||
*/
|
||||
export interface IBlockEventHandlers {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
}
|
@ -1,6 +1,4 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||||
|
||||
export class WysiwygKeyboardHandler {
|
||||
private component: any;
|
||||
@ -12,10 +10,10 @@ export class WysiwygKeyboardHandler {
|
||||
/**
|
||||
* Handles keyboard events for blocks
|
||||
*/
|
||||
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
|
||||
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// Handle slash menu navigation
|
||||
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
|
||||
this.handleSlashMenuKeyboard(e);
|
||||
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
|
||||
this.component.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -30,10 +28,22 @@ export class WysiwygKeyboardHandler {
|
||||
this.handleTab(e, block);
|
||||
break;
|
||||
case 'Enter':
|
||||
this.handleEnter(e, block);
|
||||
await this.handleEnter(e, block);
|
||||
break;
|
||||
case 'Backspace':
|
||||
this.handleBackspace(e, block);
|
||||
await this.handleBackspace(e, block);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
await this.handleArrowUp(e, block);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
await this.handleArrowDown(e, block);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
await this.handleArrowLeft(e, block);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
await this.handleArrowRight(e, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -54,19 +64,20 @@ export class WysiwygKeyboardHandler {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('bold');
|
||||
// Use Promise to ensure focus is maintained
|
||||
Promise.resolve().then(() => this.component.applyFormat('bold'));
|
||||
return true;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('italic');
|
||||
Promise.resolve().then(() => this.component.applyFormat('italic'));
|
||||
return true;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('underline');
|
||||
Promise.resolve().then(() => this.component.applyFormat('underline'));
|
||||
return true;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('link');
|
||||
Promise.resolve().then(() => this.component.applyFormat('link'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -79,7 +90,18 @@ export class WysiwygKeyboardHandler {
|
||||
if (block.type === 'code') {
|
||||
// Allow tab in code blocks
|
||||
e.preventDefault();
|
||||
document.execCommand('insertText', false, ' ');
|
||||
// Insert two spaces for tab
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(' ');
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.setEndAfter(textNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
} else if (block.type === 'list') {
|
||||
// Future: implement list indentation
|
||||
e.preventDefault();
|
||||
@ -89,7 +111,7 @@ export class WysiwygKeyboardHandler {
|
||||
/**
|
||||
* Handles Enter key
|
||||
*/
|
||||
private handleEnter(e: KeyboardEvent, block: IBlock): void {
|
||||
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
if (block.type === 'code') {
|
||||
@ -97,7 +119,7 @@ export class WysiwygKeyboardHandler {
|
||||
// Shift+Enter in code blocks creates a new block
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Normal Enter in code blocks creates new line (let browser handle it)
|
||||
return;
|
||||
@ -105,12 +127,42 @@ export class WysiwygKeyboardHandler {
|
||||
|
||||
if (!e.shiftKey) {
|
||||
if (block.type === 'list') {
|
||||
this.handleEnterInList(e, block);
|
||||
await this.handleEnterInList(e, block);
|
||||
} else {
|
||||
// Create new paragraph block
|
||||
// Split content at cursor position
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
// Get the block component
|
||||
const target = e.target as HTMLElement;
|
||||
const blockWrapper = target.closest('.block-wrapper');
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getSplitContent) {
|
||||
const splitContent = blockComponent.getSplitContent();
|
||||
|
||||
if (splitContent) {
|
||||
// Update current block with content before cursor
|
||||
blockComponent.setContent(splitContent.before);
|
||||
block.content = splitContent.before;
|
||||
|
||||
// Create new block with content after cursor
|
||||
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
||||
|
||||
// Insert the new block
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
// Update the value after both blocks are set
|
||||
this.component.updateValue();
|
||||
} else {
|
||||
// Fallback - just create empty block
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
} else {
|
||||
// No block component or method, just create empty block
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shift+Enter creates line break (let browser handle it)
|
||||
@ -119,8 +171,7 @@ export class WysiwygKeyboardHandler {
|
||||
/**
|
||||
* Handles Enter key in list blocks
|
||||
*/
|
||||
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
|
||||
const target = e.target as HTMLDivElement;
|
||||
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
@ -132,7 +183,7 @@ export class WysiwygKeyboardHandler {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Otherwise, let browser create new list item
|
||||
}
|
||||
@ -141,7 +192,7 @@ export class WysiwygKeyboardHandler {
|
||||
/**
|
||||
* Handles Backspace key
|
||||
*/
|
||||
private handleBackspace(e: KeyboardEvent, block: IBlock): void {
|
||||
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
if (block.content === '' && this.component.blocks.length > 1) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
@ -150,49 +201,184 @@ export class WysiwygKeyboardHandler {
|
||||
if (prevBlock) {
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (prevBlock.type !== 'divider') {
|
||||
blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
});
|
||||
if (prevBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash menu keyboard navigation
|
||||
* Handles ArrowUp key - navigate to previous block if at beginning
|
||||
*/
|
||||
private handleSlashMenuKeyboard(e: KeyboardEvent): void {
|
||||
const menuItems = this.component.getFilteredMenuItems();
|
||||
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
// Check if cursor is at the beginning of the block
|
||||
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
|
||||
|
||||
if (isAtStart) {
|
||||
const firstNode = target.firstChild;
|
||||
const isReallyAtStart = !firstNode ||
|
||||
(range.startContainer === firstNode && range.startOffset === 0) ||
|
||||
(range.startContainer === target && range.startOffset === 0);
|
||||
|
||||
if (isReallyAtStart) {
|
||||
e.preventDefault();
|
||||
this.component.slashMenuSelectedIndex =
|
||||
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
|
||||
break;
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.component.slashMenuSelectedIndex =
|
||||
this.component.slashMenuSelectedIndex === 0
|
||||
? menuItems.length - 1
|
||||
: this.component.slashMenuSelectedIndex - 1;
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (menuItems[this.component.slashMenuSelectedIndex]) {
|
||||
this.component.insertBlock(
|
||||
menuItems[this.component.slashMenuSelectedIndex].type as IBlock['type']
|
||||
);
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.component.closeSlashMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowDown key - navigate to next block if at end
|
||||
*/
|
||||
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
const lastNode = target.lastChild;
|
||||
|
||||
// For different block types, check if we're at the end
|
||||
let isAtEnd = false;
|
||||
|
||||
if (!lastNode) {
|
||||
// Empty block
|
||||
isAtEnd = true;
|
||||
} else if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length;
|
||||
} else if (block.type === 'list') {
|
||||
// For lists, check if we're in the last item at the end
|
||||
const lastLi = target.querySelector('li:last-child');
|
||||
if (lastLi) {
|
||||
const lastTextNode = this.getLastTextNode(lastLi);
|
||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
} else {
|
||||
// For other HTML content
|
||||
const lastTextNode = this.getLastTextNode(target);
|
||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
|
||||
if (isAtEnd) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock && nextBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowLeft key - navigate to previous block if at beginning
|
||||
*/
|
||||
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if cursor is at the very beginning (collapsed and at offset 0)
|
||||
if (range.collapsed && range.startOffset === 0) {
|
||||
const target = e.target as HTMLElement;
|
||||
const firstNode = target.firstChild;
|
||||
|
||||
// Verify we're really at the start
|
||||
const isAtStart = !firstNode ||
|
||||
(range.startContainer === firstNode) ||
|
||||
(range.startContainer === target);
|
||||
|
||||
if (isAtStart) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal left arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowRight key - navigate to next block if at end
|
||||
*/
|
||||
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Check if cursor is at the very end
|
||||
if (range.collapsed) {
|
||||
const textLength = target.textContent?.length || 0;
|
||||
let isAtEnd = false;
|
||||
|
||||
if (textLength === 0) {
|
||||
// Empty block
|
||||
isAtEnd = true;
|
||||
} else if (range.endContainer.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = range.endContainer as Text;
|
||||
isAtEnd = range.endOffset === textNode.textContent?.length;
|
||||
} else {
|
||||
// Check if we're at the end of the last text node
|
||||
const lastTextNode = this.getLastTextNode(target);
|
||||
if (lastTextNode) {
|
||||
isAtEnd = range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAtEnd) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock && nextBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal right arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash menu keyboard navigation
|
||||
* Note: This is now handled by the component directly
|
||||
*/
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { DeesModal } from '../dees-modal.js';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
|
||||
export class WysiwygModalManager {
|
||||
/**
|
||||
@ -74,13 +75,55 @@ export class WysiwygModalManager {
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): Promise<void> {
|
||||
let content: TemplateResult;
|
||||
|
||||
if (block.type === 'code') {
|
||||
content = this.getCodeBlockSettings(block, onUpdate);
|
||||
} else {
|
||||
content = html`<div style="padding: 16px;">No settings available for this block type.</div>`;
|
||||
}
|
||||
const content = html`
|
||||
<style>
|
||||
.settings-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.settings-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--dees-color-text);
|
||||
}
|
||||
.block-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.block-type-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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.block-type-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
.block-type-button.selected {
|
||||
background: var(--dees-color-primary);
|
||||
color: white;
|
||||
}
|
||||
.block-type-icon {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-container">
|
||||
${this.getBlockTypeSelector(block, onUpdate)}
|
||||
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Block Settings',
|
||||
@ -170,4 +213,62 @@ export class WysiwygModalManager {
|
||||
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block type selector
|
||||
*/
|
||||
private static getBlockTypeSelector(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const blockTypes = WysiwygShortcuts.getSlashMenuItems().filter(item => item.type !== 'divider');
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Block Type</div>
|
||||
<div class="block-type-grid">
|
||||
${blockTypes.map(item => html`
|
||||
<div
|
||||
class="block-type-button ${block.type === item.type ? 'selected' : ''}"
|
||||
@click="${async (e: MouseEvent) => {
|
||||
const oldType = block.type;
|
||||
block.type = item.type as IBlock['type'];
|
||||
|
||||
// Reset metadata for type change
|
||||
if (oldType === 'code' && block.type !== 'code') {
|
||||
delete block.metadata?.language;
|
||||
} else if (oldType === 'list' && block.type !== 'list') {
|
||||
delete block.metadata?.listType;
|
||||
} else if (block.type === 'list' && !block.metadata?.listType) {
|
||||
block.metadata = { listType: 'bullet' };
|
||||
} else if (block.type === 'code' && !block.metadata?.language) {
|
||||
// Ask for language if changing to code block
|
||||
const language = await this.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.metadata = { language };
|
||||
} else {
|
||||
// User cancelled, revert
|
||||
block.type = oldType;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal after selection
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
|
||||
if (closeButton) closeButton.click();
|
||||
}
|
||||
}}"
|
||||
>
|
||||
<span class="block-type-icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
@ -248,6 +248,8 @@ export const wysiwygStyles = css`
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
|
Reference in New Issue
Block a user