Improve Wysiwyg editor
This commit is contained in:
@ -19,7 +19,12 @@ import {
|
||||
WysiwygShortcuts,
|
||||
WysiwygBlocks,
|
||||
type ISlashMenuItem,
|
||||
WysiwygFormatting
|
||||
WysiwygFormatting,
|
||||
WysiwygBlockOperations,
|
||||
WysiwygInputHandler,
|
||||
WysiwygKeyboardHandler,
|
||||
WysiwygDragDropHandler,
|
||||
WysiwygModalManager
|
||||
} from './index.js';
|
||||
|
||||
declare global {
|
||||
@ -82,8 +87,13 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
|
||||
private editorContentRef: HTMLDivElement;
|
||||
private isComposing: boolean = false;
|
||||
private saveTimeout: any = null;
|
||||
private selectionChangeHandler = () => this.handleSelectionChange();
|
||||
|
||||
// Handler instances
|
||||
private blockOperations: WysiwygBlockOperations;
|
||||
private inputHandler: WysiwygInputHandler;
|
||||
private keyboardHandler: WysiwygKeyboardHandler;
|
||||
private dragDropHandler: WysiwygDragDropHandler;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
@ -91,6 +101,15 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
wysiwygStyles
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Initialize handlers
|
||||
this.blockOperations = new WysiwygBlockOperations(this);
|
||||
this.inputHandler = new WysiwygInputHandler(this);
|
||||
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
||||
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
}
|
||||
@ -99,6 +118,8 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
await super.disconnectedCallback();
|
||||
// Remove selection listener
|
||||
document.removeEventListener('selectionchange', this.selectionChangeHandler);
|
||||
// Clean up handlers
|
||||
this.inputHandler?.destroy();
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
@ -132,28 +153,29 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
|
||||
private renderBlock(block: IBlock): TemplateResult {
|
||||
const isSelected = this.selectedBlockId === block.id;
|
||||
const isDragging = this.draggedBlockId === block.id;
|
||||
const isDragOver = this.dragOverBlockId === block.id;
|
||||
const isDragging = this.dragDropHandler.isDragging(block.id);
|
||||
const isDragOver = this.dragDropHandler.isDragOver(block.id);
|
||||
const dragOverClasses = this.dragDropHandler.getDragOverClasses(block.id);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="block-wrapper ${isDragging ? 'dragging' : ''} ${isDragOver && this.dragOverPosition === 'before' ? 'drag-over-before' : ''} ${isDragOver && this.dragOverPosition === 'after' ? 'drag-over-after' : ''}"
|
||||
class="block-wrapper ${isDragging ? 'dragging' : ''} ${dragOverClasses}"
|
||||
data-block-id="${block.id}"
|
||||
@dragover="${(e: DragEvent) => this.handleDragOver(e, block)}"
|
||||
@drop="${(e: DragEvent) => this.handleDrop(e, block)}"
|
||||
@dragleave="${() => this.handleDragLeave(block)}"
|
||||
@dragover="${(e: DragEvent) => this.dragDropHandler.handleDragOver(e, block)}"
|
||||
@drop="${(e: DragEvent) => this.dragDropHandler.handleDrop(e, block)}"
|
||||
@dragleave="${() => this.dragDropHandler.handleDragLeave(block)}"
|
||||
>
|
||||
${block.type !== 'divider' ? html`
|
||||
<div
|
||||
class="drag-handle"
|
||||
draggable="true"
|
||||
@dragstart="${(e: DragEvent) => this.handleDragStart(e, block)}"
|
||||
@dragend="${() => this.handleDragEnd()}"
|
||||
@dragstart="${(e: DragEvent) => this.dragDropHandler.handleDragStart(e, block)}"
|
||||
@dragend="${() => this.dragDropHandler.handleDragEnd()}"
|
||||
></div>
|
||||
` : ''}
|
||||
${WysiwygBlocks.renderBlock(block, isSelected, {
|
||||
onInput: (e: InputEvent) => this.handleBlockInput(e, block),
|
||||
onKeyDown: (e: KeyboardEvent) => this.handleBlockKeyDown(e, block),
|
||||
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,
|
||||
@ -166,7 +188,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
@click="${(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.showBlockSettingsModal(block);
|
||||
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
|
||||
this.updateValue();
|
||||
this.requestUpdate();
|
||||
});
|
||||
}}"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
@ -180,7 +205,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
`;
|
||||
}
|
||||
|
||||
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
public getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
||||
return allItems.filter(item =>
|
||||
this.slashMenuFilter === '' ||
|
||||
@ -279,7 +304,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
return;
|
||||
} else if (detectedType.type === 'code') {
|
||||
// For code blocks, ask for language
|
||||
this.showLanguageSelectionModal().then(language => {
|
||||
WysiwygModalManager.showLanguageSelectionModal().then(language => {
|
||||
if (language) {
|
||||
block.type = 'code';
|
||||
block.content = '';
|
||||
@ -330,12 +355,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
|
||||
// Don't update value on every input - let the browser handle typing normally
|
||||
// But schedule a save after a delay
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.updateValue();
|
||||
}, 1000); // Save after 1 second of inactivity
|
||||
// Removed - now handled by inputHandler
|
||||
}
|
||||
|
||||
private handleBlockKeyDown(e: KeyboardEvent, block: IBlock) {
|
||||
@ -468,7 +488,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
}
|
||||
|
||||
private closeSlashMenu() {
|
||||
public closeSlashMenu() {
|
||||
if (this.showSlashMenu && this.selectedBlockId) {
|
||||
// Clear the slash command from the content if menu is closing without selection
|
||||
const currentBlock = this.blocks.find(b => b.id === this.selectedBlockId);
|
||||
@ -592,14 +612,14 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
}
|
||||
|
||||
private async insertBlock(type: IBlock['type']) {
|
||||
public async insertBlock(type: IBlock['type']) {
|
||||
const currentBlockIndex = this.blocks.findIndex(b => b.id === this.selectedBlockId);
|
||||
const currentBlock = this.blocks[currentBlockIndex];
|
||||
|
||||
if (currentBlock) {
|
||||
// If it's a code block, ask for language
|
||||
if (type === 'code') {
|
||||
const language = await this.showLanguageSelectionModal();
|
||||
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
||||
if (!language) {
|
||||
// User cancelled
|
||||
this.closeSlashMenu();
|
||||
@ -932,7 +952,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
this.selectedText = '';
|
||||
}
|
||||
|
||||
private applyFormat(command: string): void {
|
||||
public applyFormat(command: string): void {
|
||||
// Save current selection before applying format
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
@ -3,4 +3,9 @@ export * from './wysiwyg.styles.js';
|
||||
export * from './wysiwyg.converters.js';
|
||||
export * from './wysiwyg.shortcuts.js';
|
||||
export * from './wysiwyg.blocks.js';
|
||||
export * from './wysiwyg.formatting.js';
|
||||
export * from './wysiwyg.formatting.js';
|
||||
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';
|
146
ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts
Normal file
146
ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
|
||||
export class WysiwygBlockOperations {
|
||||
private component: any; // Will be typed properly when imported
|
||||
|
||||
constructor(component: any) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new block with the specified parameters
|
||||
*/
|
||||
createBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
|
||||
return {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type,
|
||||
content,
|
||||
...(metadata && { metadata })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a block after the specified block
|
||||
*/
|
||||
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): void {
|
||||
const blocks = this.component.blocks;
|
||||
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
||||
|
||||
this.component.blocks = [
|
||||
...blocks.slice(0, blockIndex + 1),
|
||||
newBlock,
|
||||
...blocks.slice(blockIndex + 1)
|
||||
];
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
setTimeout(() => {
|
||||
this.focusBlock(newBlock.id);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a block by its ID
|
||||
*/
|
||||
removeBlock(blockId: string): void {
|
||||
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a block by its ID
|
||||
*/
|
||||
findBlock(blockId: string): IBlock | undefined {
|
||||
return this.component.blocks.find((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the index of a block
|
||||
*/
|
||||
getBlockIndex(blockId: string): number {
|
||||
return this.component.blocks.findIndex((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a specific block
|
||||
*/
|
||||
focusBlock(blockId: string, cursorPosition: 'start' | 'end' = 'start'): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content of a block
|
||||
*/
|
||||
updateBlockContent(blockId: string, content: string): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
this.component.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a block to a different type
|
||||
*/
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
block.type = newType;
|
||||
block.content = '';
|
||||
if (metadata) {
|
||||
block.metadata = metadata;
|
||||
}
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block to a new position
|
||||
*/
|
||||
moveBlock(blockId: string, targetIndex: number): void {
|
||||
const blocks = [...this.component.blocks];
|
||||
const currentIndex = this.getBlockIndex(blockId);
|
||||
|
||||
if (currentIndex === -1 || targetIndex < 0 || targetIndex >= blocks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedBlock] = blocks.splice(currentIndex, 1);
|
||||
blocks.splice(targetIndex, 0, movedBlock);
|
||||
|
||||
this.component.blocks = blocks;
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the previous block
|
||||
*/
|
||||
getPreviousBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index > 0 ? this.component.blocks[index - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next block
|
||||
*/
|
||||
getNextBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index < this.component.blocks.length - 1 ? this.component.blocks[index + 1] : null;
|
||||
}
|
||||
}
|
156
ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts
Normal file
156
ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygDragDropHandler {
|
||||
private component: any;
|
||||
private draggedBlockId: string | null = null;
|
||||
private dragOverBlockId: string | null = null;
|
||||
private dragOverPosition: 'before' | 'after' | null = null;
|
||||
|
||||
constructor(component: any) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current drag state
|
||||
*/
|
||||
get dragState() {
|
||||
return {
|
||||
draggedBlockId: this.draggedBlockId,
|
||||
dragOverBlockId: this.dragOverBlockId,
|
||||
dragOverPosition: this.dragOverPosition
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag start
|
||||
*/
|
||||
handleDragStart(e: DragEvent, block: IBlock): void {
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
this.draggedBlockId = block.id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', block.id);
|
||||
|
||||
// Update UI state
|
||||
this.updateComponentState();
|
||||
|
||||
// Add slight delay to show dragging state
|
||||
setTimeout(() => {
|
||||
this.component.requestUpdate();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag end
|
||||
*/
|
||||
handleDragEnd(): void {
|
||||
this.draggedBlockId = null;
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.updateComponentState();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag over
|
||||
*/
|
||||
handleDragOver(e: DragEvent, block: IBlock): void {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
this.dragOverBlockId = block.id;
|
||||
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
||||
this.updateComponentState();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag leave
|
||||
*/
|
||||
handleDragLeave(block: IBlock): void {
|
||||
if (this.dragOverBlockId === block.id) {
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.updateComponentState();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drop
|
||||
*/
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
|
||||
|
||||
const blocks = [...this.component.blocks];
|
||||
const draggedIndex = blocks.findIndex(b => b.id === this.draggedBlockId);
|
||||
const targetIndex = blocks.findIndex(b => b.id === targetBlock.id);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) return;
|
||||
|
||||
// Remove the dragged block
|
||||
const [draggedBlock] = blocks.splice(draggedIndex, 1);
|
||||
|
||||
// Calculate the new index
|
||||
let newIndex = targetIndex;
|
||||
if (this.dragOverPosition === 'after') {
|
||||
newIndex = draggedIndex < targetIndex ? targetIndex : targetIndex + 1;
|
||||
} else {
|
||||
newIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex;
|
||||
}
|
||||
|
||||
// Insert at new position
|
||||
blocks.splice(newIndex, 0, draggedBlock);
|
||||
|
||||
// Update blocks
|
||||
this.component.blocks = blocks;
|
||||
this.component.updateValue();
|
||||
this.handleDragEnd();
|
||||
|
||||
// Focus the moved block
|
||||
setTimeout(() => {
|
||||
const blockOps = this.component.blockOperations;
|
||||
if (draggedBlock.type !== 'divider') {
|
||||
blockOps.focusBlock(draggedBlock.id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates component drag state
|
||||
*/
|
||||
private updateComponentState(): void {
|
||||
this.component.draggedBlockId = this.draggedBlockId;
|
||||
this.component.dragOverBlockId = this.dragOverBlockId;
|
||||
this.component.dragOverPosition = this.dragOverPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block is being dragged
|
||||
*/
|
||||
isDragging(blockId: string): boolean {
|
||||
return this.draggedBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block has drag over state
|
||||
*/
|
||||
isDragOver(blockId: string): boolean {
|
||||
return this.dragOverBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets drag over CSS classes for a block
|
||||
*/
|
||||
getDragOverClasses(blockId: string): string {
|
||||
if (!this.isDragOver(blockId)) return '';
|
||||
return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after';
|
||||
}
|
||||
}
|
193
ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts
Normal file
193
ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||||
|
||||
export class WysiwygInputHandler {
|
||||
private component: any;
|
||||
private saveTimeout: any = null;
|
||||
|
||||
constructor(component: any) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input events for blocks
|
||||
*/
|
||||
handleBlockInput(e: InputEvent, block: IBlock): void {
|
||||
if (this.component.isComposing) return;
|
||||
|
||||
const target = e.target as HTMLDivElement;
|
||||
const textContent = target.textContent || '';
|
||||
|
||||
// Update block content based on type
|
||||
this.updateBlockContent(block, target);
|
||||
|
||||
// Check for block type transformations
|
||||
const detectedType = this.detectBlockTypeIntent(textContent);
|
||||
if (detectedType && detectedType.type !== block.type) {
|
||||
e.preventDefault();
|
||||
this.handleBlockTransformation(block, detectedType, target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
this.handleSlashCommand(textContent, target);
|
||||
|
||||
// Schedule auto-save
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
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 || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the user is trying to create a specific block type
|
||||
*/
|
||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
||||
// Check heading patterns
|
||||
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
||||
if (headingResult) {
|
||||
return headingResult;
|
||||
}
|
||||
|
||||
// Check list patterns
|
||||
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
||||
if (listResult) {
|
||||
return listResult;
|
||||
}
|
||||
|
||||
// Check quote pattern
|
||||
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
||||
return { type: 'quote' };
|
||||
}
|
||||
|
||||
// Check code pattern
|
||||
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
||||
return { type: 'code' };
|
||||
}
|
||||
|
||||
// Check divider pattern
|
||||
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
||||
return { type: 'divider' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles block type transformation
|
||||
*/
|
||||
private async handleBlockTransformation(
|
||||
block: IBlock,
|
||||
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
|
||||
target: HTMLDivElement
|
||||
): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
if (detectedType.type === 'list') {
|
||||
block.type = 'list';
|
||||
block.content = '';
|
||||
block.metadata = { listType: detectedType.listType };
|
||||
|
||||
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
||||
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
||||
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
WysiwygBlocks.focusListItem(target);
|
||||
}, 0);
|
||||
} else if (detectedType.type === 'divider') {
|
||||
block.type = 'divider';
|
||||
block.content = ' ';
|
||||
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
} else if (detectedType.type === 'code') {
|
||||
const language = await this.component.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.type = 'code';
|
||||
block.content = '';
|
||||
block.metadata = { language };
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
} else {
|
||||
block.type = detectedType.type;
|
||||
block.content = '';
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
this.component.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 rect = target.getBoundingClientRect();
|
||||
const containerRect = this.component.shadowRoot!.querySelector('.wysiwyg-container')!.getBoundingClientRect();
|
||||
|
||||
this.component.slashMenuPosition = {
|
||||
x: rect.left - containerRect.left,
|
||||
y: rect.bottom - containerRect.top + 4
|
||||
};
|
||||
}
|
||||
this.component.slashMenuFilter = textContent.slice(1);
|
||||
} else if (!textContent.startsWith('/')) {
|
||||
this.component.closeSlashMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules auto-save after a delay
|
||||
*/
|
||||
private scheduleAutoSave(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.component.updateValue();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
}
|
||||
}
|
198
ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
198
ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
@ -0,0 +1,198 @@
|
||||
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;
|
||||
|
||||
constructor(component: any) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard events for blocks
|
||||
*/
|
||||
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
|
||||
// Handle slash menu navigation
|
||||
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
|
||||
this.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle formatting shortcuts
|
||||
if (this.handleFormattingShortcuts(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
this.handleTab(e, block);
|
||||
break;
|
||||
case 'Enter':
|
||||
this.handleEnter(e, block);
|
||||
break;
|
||||
case 'Backspace':
|
||||
this.handleBackspace(e, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if key is for slash menu navigation
|
||||
*/
|
||||
private isSlashMenuKey(key: string): boolean {
|
||||
return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles formatting keyboard shortcuts
|
||||
*/
|
||||
private handleFormattingShortcuts(e: KeyboardEvent): boolean {
|
||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('bold');
|
||||
return true;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('italic');
|
||||
return true;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('underline');
|
||||
return true;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
this.component.applyFormat('link');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Tab key
|
||||
*/
|
||||
private handleTab(e: KeyboardEvent, block: IBlock): void {
|
||||
if (block.type === 'code') {
|
||||
// Allow tab in code blocks
|
||||
e.preventDefault();
|
||||
document.execCommand('insertText', false, ' ');
|
||||
} else if (block.type === 'list') {
|
||||
// Future: implement list indentation
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key
|
||||
*/
|
||||
private handleEnter(e: KeyboardEvent, block: IBlock): void {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
if (block.type === 'code') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter in code blocks creates a new block
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Normal Enter in code blocks creates new line (let browser handle it)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.shiftKey) {
|
||||
if (block.type === 'list') {
|
||||
this.handleEnterInList(e, block);
|
||||
} else {
|
||||
// Create new paragraph block
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
}
|
||||
// Shift+Enter creates line break (let browser handle it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key in list blocks
|
||||
*/
|
||||
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
|
||||
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 blockOps = this.component.blockOperations;
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Otherwise, let browser create new list item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Backspace key
|
||||
*/
|
||||
private handleBackspace(e: KeyboardEvent, block: IBlock): void {
|
||||
if (block.content === '' && this.component.blocks.length > 1) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (prevBlock.type !== 'divider') {
|
||||
blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash menu keyboard navigation
|
||||
*/
|
||||
private handleSlashMenuKeyboard(e: KeyboardEvent): void {
|
||||
const menuItems = this.component.getFilteredMenuItems();
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.component.slashMenuSelectedIndex =
|
||||
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
|
||||
break;
|
||||
|
||||
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']
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this.component.closeSlashMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
173
ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts
Normal file
173
ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { DeesModal } from '../dees-modal.js';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygModalManager {
|
||||
/**
|
||||
* Shows language selection modal for code blocks
|
||||
*/
|
||||
static async showLanguageSelectionModal(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedLanguage: string | null = null;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Select Programming Language',
|
||||
content: html`
|
||||
<style>
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 12px;
|
||||
background: var(--dees-color-box);
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.language-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
</style>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div class="language-button" @click="${(e: MouseEvent) => {
|
||||
selectedLanguage = lang.toLowerCase();
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const okButton = modal.shadowRoot?.querySelector('.bottomButton.ok') as HTMLElement;
|
||||
if (okButton) okButton.click();
|
||||
}
|
||||
}}">${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OK',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(selectedLanguage);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows block settings modal
|
||||
*/
|
||||
static async showBlockSettingsModal(
|
||||
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>`;
|
||||
}
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Block Settings',
|
||||
content,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Close',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets code block settings content
|
||||
*/
|
||||
private static getCodeBlockSettings(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const currentLanguage = block.metadata?.language || 'plain text';
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.settings-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 8px;
|
||||
background: var(--dees-color-box);
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.language-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
.language-button.selected {
|
||||
background: var(--dees-color-primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Programming Language</div>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${(e: MouseEvent) => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.language = lang.toLowerCase();
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
|
||||
if (closeButton) closeButton.click();
|
||||
}
|
||||
}}">${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available programming languages
|
||||
*/
|
||||
private static getLanguages(): string[] {
|
||||
return [
|
||||
'JavaScript', 'TypeScript', 'Python', 'Java',
|
||||
'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS',
|
||||
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
|
||||
];
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user