fix(wysiwyg):Improve Wysiwyg editor
This commit is contained in:
@ -6,7 +6,7 @@ import {
|
|||||||
customElement,
|
customElement,
|
||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
property,
|
property,
|
||||||
html,
|
static as html,
|
||||||
cssManager,
|
cssManager,
|
||||||
state,
|
state,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
@ -44,7 +44,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
public outputFormat: OutputFormat = 'html';
|
public outputFormat: OutputFormat = 'html';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private blocks: IBlock[] = [
|
public blocks: IBlock[] = [
|
||||||
{
|
{
|
||||||
id: WysiwygShortcuts.generateBlockId(),
|
id: WysiwygShortcuts.generateBlockId(),
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
@ -53,39 +53,43 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Not using @state to avoid re-renders when selection changes
|
// Not using @state to avoid re-renders when selection changes
|
||||||
private selectedBlockId: string | null = null;
|
public selectedBlockId: string | null = null;
|
||||||
|
|
||||||
// Slash menu is now globally rendered
|
// Slash menu is now globally rendered
|
||||||
private slashMenu = DeesSlashMenu.getInstance();
|
public slashMenu = DeesSlashMenu.getInstance();
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private draggedBlockId: string | null = null;
|
public draggedBlockId: string | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private dragOverBlockId: string | null = null;
|
public dragOverBlockId: string | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private dragOverPosition: 'before' | 'after' | null = null;
|
public dragOverPosition: 'before' | 'after' | null = null;
|
||||||
|
|
||||||
// Formatting menu is now globally rendered
|
// Formatting menu is now globally rendered
|
||||||
private formattingMenu = DeesFormattingMenu.getInstance();
|
public formattingMenu = DeesFormattingMenu.getInstance();
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private selectedText: string = '';
|
private selectedText: string = '';
|
||||||
|
|
||||||
private editorContentRef: HTMLDivElement;
|
public editorContentRef: HTMLDivElement;
|
||||||
private isComposing: boolean = false;
|
public isComposing: boolean = false;
|
||||||
private selectionChangeHandler = () => this.handleSelectionChange();
|
private selectionChangeTimeout: any;
|
||||||
|
private selectionChangeHandler = () => {
|
||||||
|
// Throttle selection change events
|
||||||
|
if (this.selectionChangeTimeout) {
|
||||||
|
clearTimeout(this.selectionChangeTimeout);
|
||||||
|
}
|
||||||
|
this.selectionChangeTimeout = setTimeout(() => this.handleSelectionChange(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
// Handler instances
|
// Handler instances
|
||||||
private blockOperations: WysiwygBlockOperations;
|
public blockOperations: WysiwygBlockOperations;
|
||||||
private inputHandler: WysiwygInputHandler;
|
private inputHandler: WysiwygInputHandler;
|
||||||
private keyboardHandler: WysiwygKeyboardHandler;
|
private keyboardHandler: WysiwygKeyboardHandler;
|
||||||
private dragDropHandler: WysiwygDragDropHandler;
|
private dragDropHandler: WysiwygDragDropHandler;
|
||||||
|
|
||||||
// Content cache to avoid triggering re-renders during typing
|
|
||||||
private contentCache: Map<string, string> = new Map();
|
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
...DeesInputBase.baseStyles,
|
...DeesInputBase.baseStyles,
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
@ -116,6 +120,11 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
clearTimeout(this.blurTimeout);
|
clearTimeout(this.blurTimeout);
|
||||||
this.blurTimeout = null;
|
this.blurTimeout = null;
|
||||||
}
|
}
|
||||||
|
// Clean up selection change timeout
|
||||||
|
if (this.selectionChangeTimeout) {
|
||||||
|
clearTimeout(this.selectionChangeTimeout);
|
||||||
|
this.selectionChangeTimeout = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
@ -141,7 +150,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
/**
|
/**
|
||||||
* Renders all blocks programmatically without triggering re-renders
|
* Renders all blocks programmatically without triggering re-renders
|
||||||
*/
|
*/
|
||||||
private renderBlocksProgrammatically() {
|
public renderBlocksProgrammatically() {
|
||||||
if (!this.editorContentRef) return;
|
if (!this.editorContentRef) return;
|
||||||
|
|
||||||
// Clear existing blocks
|
// Clear existing blocks
|
||||||
@ -157,7 +166,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
/**
|
/**
|
||||||
* Creates a block element programmatically
|
* Creates a block element programmatically
|
||||||
*/
|
*/
|
||||||
private createBlockElement(block: IBlock): HTMLElement {
|
public createBlockElement(block: IBlock): HTMLElement {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'block-wrapper';
|
wrapper.className = 'block-wrapper';
|
||||||
wrapper.setAttribute('data-block-id', block.id);
|
wrapper.setAttribute('data-block-id', block.id);
|
||||||
@ -200,7 +209,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
settings.addEventListener('click', (e) => {
|
settings.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => {
|
WysiwygModalManager.showBlockSettingsModal(block, () => {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
// Re-render only the updated block
|
// Re-render only the updated block
|
||||||
this.updateBlockElement(block.id);
|
this.updateBlockElement(block.id);
|
||||||
@ -220,7 +229,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
/**
|
/**
|
||||||
* Updates a specific block element
|
* Updates a specific block element
|
||||||
*/
|
*/
|
||||||
private updateBlockElement(blockId: string) {
|
public updateBlockElement(blockId: string) {
|
||||||
const block = this.blocks.find(b => b.id === blockId);
|
const block = this.blocks.find(b => b.id === blockId);
|
||||||
if (!block) return;
|
if (!block) return;
|
||||||
|
|
||||||
@ -257,7 +266,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
private handleSlashMenuKeyboard(e: KeyboardEvent) {
|
public handleSlashMenuKeyboard(e: KeyboardEvent) {
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -306,39 +315,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.slashMenu.hide();
|
this.slashMenu.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
|
||||||
// Check heading patterns
|
|
||||||
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
|
||||||
if (headingResult) {
|
|
||||||
return headingResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check list patterns
|
|
||||||
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
|
||||||
if (listResult) {
|
|
||||||
return listResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check quote pattern
|
|
||||||
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
|
||||||
return { type: 'quote' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check code pattern
|
|
||||||
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
|
||||||
return { type: 'code' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check divider pattern
|
|
||||||
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
|
||||||
return { type: 'divider' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't automatically revert to paragraph - blocks should keep their type
|
|
||||||
// unless explicitly changed by the user
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleBlockFocus(block: IBlock) {
|
private handleBlockFocus(block: IBlock) {
|
||||||
// Clear any pending blur timeout when focusing
|
// Clear any pending blur timeout when focusing
|
||||||
if (this.blurTimeout) {
|
if (this.blurTimeout) {
|
||||||
@ -533,7 +509,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateValue() {
|
public updateValue() {
|
||||||
if (this.outputFormat === 'html') {
|
if (this.outputFormat === 'html') {
|
||||||
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
|
this.value = WysiwygConverters.getHtmlOutput(this.blocks);
|
||||||
} else {
|
} else {
|
||||||
@ -625,26 +601,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.importBlocks(state.blocks);
|
this.importBlocks(state.blocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and Drop Handlers
|
|
||||||
private handleDragStart(e: DragEvent, block: IBlock): void {
|
|
||||||
if (!e.dataTransfer) return;
|
|
||||||
|
|
||||||
this.draggedBlockId = block.id;
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
e.dataTransfer.setData('text/plain', block.id);
|
|
||||||
|
|
||||||
// Add dragging class to the wrapper
|
|
||||||
setTimeout(() => {
|
|
||||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.classList.add('dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add dragging class to editor content
|
|
||||||
this.editorContentRef.classList.add('dragging');
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragEnd(): void {
|
private handleDragEnd(): void {
|
||||||
// Remove all drag-related classes
|
// Remove all drag-related classes
|
||||||
if (this.draggedBlockId) {
|
if (this.draggedBlockId) {
|
||||||
@ -668,44 +624,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.dragOverPosition = null;
|
this.dragOverPosition = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDragOver(e: DragEvent, block: IBlock): void {
|
public handleDrop(e: DragEvent, targetBlock: 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;
|
|
||||||
|
|
||||||
// Remove previous drag-over classes
|
|
||||||
if (this.dragOverBlockId) {
|
|
||||||
const prevWrapper = this.editorContentRef.querySelector(`[data-block-id="${this.dragOverBlockId}"]`);
|
|
||||||
if (prevWrapper) {
|
|
||||||
prevWrapper.classList.remove('drag-over-before', 'drag-over-after');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dragOverBlockId = block.id;
|
|
||||||
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
|
||||||
|
|
||||||
// Add new drag-over class
|
|
||||||
const wrapper = e.currentTarget as HTMLElement;
|
|
||||||
wrapper.classList.add(`drag-over-${this.dragOverPosition}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragLeave(block: IBlock): void {
|
|
||||||
if (this.dragOverBlockId === block.id) {
|
|
||||||
const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
|
||||||
if (wrapper) {
|
|
||||||
wrapper.classList.remove('drag-over-before', 'drag-over-after');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dragOverBlockId = null;
|
|
||||||
this.dragOverPosition = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDrop(e: DragEvent, targetBlock: IBlock): void {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
|
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
|
||||||
@ -746,7 +665,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private handleTextSelection(e: MouseEvent): void {
|
private handleTextSelection(_e: MouseEvent): void {
|
||||||
// Don't interfere with slash menu
|
// Don't interfere with slash menu
|
||||||
if (this.slashMenu.visible) return;
|
if (this.slashMenu.visible) return;
|
||||||
|
|
||||||
@ -960,69 +879,4 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal methods moved to WysiwygModalManager
|
|
||||||
private async showLanguageSelectionModal(): Promise<string | null> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let selectedLanguage: string | null = null;
|
|
||||||
|
|
||||||
DeesModal.createAndShow({
|
|
||||||
heading: 'Select Programming Language',
|
|
||||||
content: html`
|
|
||||||
<style>
|
|
||||||
.language-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.language-button {
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--dees-color-box);
|
|
||||||
border: 1px solid var(--dees-color-line-bright);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.language-button:hover {
|
|
||||||
background: var(--dees-color-box-highlight);
|
|
||||||
border-color: var(--dees-color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div class="language-grid">
|
|
||||||
${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html`
|
|
||||||
<div class="language-button" @click="${(e: MouseEvent) => {
|
|
||||||
selectedLanguage = lang.toLowerCase();
|
|
||||||
// Find and click the hidden OK button to close the modal
|
|
||||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
|
||||||
if (modal) {
|
|
||||||
const okButton = modal.shadowRoot?.querySelector('.bottomButton.ok') as HTMLElement;
|
|
||||||
if (okButton) okButton.click();
|
|
||||||
}
|
|
||||||
}}">${lang}</div>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
menuOptions: [
|
|
||||||
{
|
|
||||||
name: 'Cancel',
|
|
||||||
action: async (modal) => {
|
|
||||||
modal.destroy();
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'OK',
|
|
||||||
action: async (modal) => {
|
|
||||||
modal.destroy();
|
|
||||||
resolve(selectedLanguage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal methods have been moved to WysiwygModalManager
|
|
||||||
}
|
}
|
@ -1,18 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
customElement,
|
customElement,
|
||||||
property,
|
property,
|
||||||
html,
|
static as html,
|
||||||
DeesElement,
|
DeesElement,
|
||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
cssManager,
|
cssManager,
|
||||||
css,
|
css,
|
||||||
query,
|
query,
|
||||||
unsafeStatic,
|
|
||||||
static as staticHtml,
|
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@ -271,10 +270,71 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
// Mark that content has been initialized
|
// Mark that content has been initialized
|
||||||
this.contentInitialized = true;
|
this.contentInitialized = true;
|
||||||
|
|
||||||
|
// For code blocks, the actual contenteditable block is nested
|
||||||
|
const editableBlock = this.block.type === 'code'
|
||||||
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
||||||
|
: this.blockElement;
|
||||||
|
|
||||||
// Ensure the block element maintains its content
|
// Ensure the block element maintains its content
|
||||||
if (this.blockElement) {
|
if (editableBlock) {
|
||||||
this.blockElement.setAttribute('data-block-id', this.block.id);
|
editableBlock.setAttribute('data-block-id', this.block.id);
|
||||||
this.blockElement.setAttribute('data-block-type', this.block.type);
|
editableBlock.setAttribute('data-block-type', this.block.type);
|
||||||
|
|
||||||
|
// Set up all event handlers manually to avoid Lit re-renders
|
||||||
|
editableBlock.addEventListener('input', (e) => {
|
||||||
|
this.logCursorPosition('input');
|
||||||
|
this.handlers?.onInput?.(e as InputEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('keydown', (e) => {
|
||||||
|
this.handlers?.onKeyDown?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('keyup', (e) => {
|
||||||
|
this.logCursorPosition('keyup', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('focus', () => {
|
||||||
|
this.handlers?.onFocus?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('blur', () => {
|
||||||
|
this.handlers?.onBlur?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('compositionstart', () => {
|
||||||
|
this.handlers?.onCompositionStart?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('compositionend', () => {
|
||||||
|
this.handlers?.onCompositionEnd?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('mouseup', (e) => {
|
||||||
|
this.logCursorPosition('mouseup');
|
||||||
|
this.handleMouseUp(e);
|
||||||
|
this.handlers?.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
editableBlock.addEventListener('click', () => {
|
||||||
|
this.logCursorPosition('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial content if needed
|
||||||
|
if (this.block.content) {
|
||||||
|
if (this.block.type === 'code') {
|
||||||
|
editableBlock.textContent = this.block.content;
|
||||||
|
} else if (this.block.type === 'list') {
|
||||||
|
editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
||||||
|
} else {
|
||||||
|
editableBlock.innerHTML = this.block.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update blockElement reference for code blocks
|
||||||
|
if (this.block.type === 'code') {
|
||||||
|
this.blockElement = editableBlock;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -298,41 +358,20 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
class="block code ${this.isSelected ? 'selected' : ''}"
|
class="block code ${this.isSelected ? 'selected' : ''}"
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
data-block-type="${this.block.type}"
|
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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholder = this.getPlaceholder();
|
const placeholder = this.getPlaceholder();
|
||||||
const initialContent = this.getInitialContent();
|
|
||||||
|
|
||||||
return staticHtml`
|
// Return static HTML without event bindings
|
||||||
|
return html`
|
||||||
<div
|
<div
|
||||||
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
data-placeholder="${placeholder}"
|
data-placeholder="${placeholder}"
|
||||||
@input="${this.handlers?.onInput}"
|
></div>
|
||||||
@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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,12 +392,6 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInitialContent(): string {
|
|
||||||
if (this.block.type === 'list') {
|
|
||||||
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
|
||||||
}
|
|
||||||
return this.block.content || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public focus(): void {
|
public focus(): void {
|
||||||
if (!this.blockElement) return;
|
if (!this.blockElement) return;
|
||||||
@ -391,34 +424,13 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
|
|
||||||
// Set cursor position after focus is established
|
// Set cursor position after focus is established
|
||||||
const setCursor = () => {
|
const setCursor = () => {
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) return;
|
|
||||||
|
|
||||||
if (position === 'start') {
|
if (position === 'start') {
|
||||||
this.setCursorToStart();
|
this.setCursorToStart();
|
||||||
} else if (position === 'end') {
|
} else if (position === 'end') {
|
||||||
this.setCursorToEnd();
|
this.setCursorToEnd();
|
||||||
} else if (typeof position === 'number') {
|
} else if (typeof position === 'number') {
|
||||||
// Set cursor at specific position
|
// Use the new selection utility to set cursor position
|
||||||
const range = document.createRange();
|
WysiwygSelection.setCursorPosition(this.blockElement, position);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -501,47 +513,121 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
public getSplitContent(): { before: string; after: string } | null {
|
public getSplitContent(): { before: string; after: string } | null {
|
||||||
if (!this.blockElement) return null;
|
if (!this.blockElement) return null;
|
||||||
|
|
||||||
const selection = window.getSelection();
|
// Get the full content first
|
||||||
if (!selection || selection.rangeCount === 0) {
|
const fullContent = this.getContent();
|
||||||
|
console.log('getSplitContent: Full content:', {
|
||||||
|
content: fullContent,
|
||||||
|
length: fullContent.length,
|
||||||
|
blockType: this.block.type
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get selection info using the new utility that handles Shadow DOM
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('getSplitContent: No selection, returning all content as before');
|
||||||
return {
|
return {
|
||||||
before: this.getContent(),
|
before: fullContent,
|
||||||
after: ''
|
after: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
|
|
||||||
// Check if selection is within this block
|
// Check if selection is within this block
|
||||||
if (!this.blockElement.contains(range.commonAncestorContainer)) {
|
if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) {
|
||||||
|
console.log('getSplitContent: Selection not in this block');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone the range to extract content before and after cursor
|
// Get cursor position as a number
|
||||||
const beforeRange = range.cloneRange();
|
const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!);
|
||||||
|
console.log('getSplitContent: Cursor position:', {
|
||||||
|
cursorPosition,
|
||||||
|
contentLength: fullContent.length,
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
collapsed: selectionInfo.collapsed
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle special cases for different block types
|
||||||
|
if (this.block.type === 'code') {
|
||||||
|
// For code blocks, split text content
|
||||||
|
const fullText = this.blockElement.textContent || '';
|
||||||
|
const textNode = this.getFirstTextNode(this.blockElement);
|
||||||
|
|
||||||
|
if (textNode && selectionInfo.startContainer === textNode) {
|
||||||
|
const before = fullText.substring(0, selectionInfo.startOffset);
|
||||||
|
const after = fullText.substring(selectionInfo.startOffset);
|
||||||
|
|
||||||
|
console.log('getSplitContent: Code block split result:', {
|
||||||
|
cursorPosition,
|
||||||
|
contentLength: fullText.length,
|
||||||
|
beforeContent: before,
|
||||||
|
beforeLength: before.length,
|
||||||
|
afterContent: after,
|
||||||
|
afterLength: after.length,
|
||||||
|
startOffset: selectionInfo.startOffset
|
||||||
|
});
|
||||||
|
|
||||||
|
return { before, after };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other block types, extract HTML content
|
||||||
|
try {
|
||||||
|
// Create a temporary range to get content before cursor
|
||||||
|
const beforeRange = document.createRange();
|
||||||
beforeRange.selectNodeContents(this.blockElement);
|
beforeRange.selectNodeContents(this.blockElement);
|
||||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
const afterRange = range.cloneRange();
|
// Create a temporary range to get content after cursor
|
||||||
|
const afterRange = document.createRange();
|
||||||
afterRange.selectNodeContents(this.blockElement);
|
afterRange.selectNodeContents(this.blockElement);
|
||||||
afterRange.setStart(range.endContainer, range.endOffset);
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
// Extract content
|
// Clone HTML content (not extract, to avoid modifying the DOM)
|
||||||
const beforeFragment = beforeRange.cloneContents();
|
const beforeContents = beforeRange.cloneContents();
|
||||||
const afterFragment = afterRange.cloneContents();
|
const afterContents = afterRange.cloneContents();
|
||||||
|
|
||||||
// Convert to HTML
|
// Convert to HTML strings
|
||||||
const tempDiv = document.createElement('div');
|
const tempDiv = document.createElement('div');
|
||||||
tempDiv.appendChild(beforeFragment);
|
tempDiv.appendChild(beforeContents);
|
||||||
const beforeHtml = tempDiv.innerHTML;
|
const beforeHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
tempDiv.innerHTML = '';
|
tempDiv.innerHTML = '';
|
||||||
tempDiv.appendChild(afterFragment);
|
tempDiv.appendChild(afterContents);
|
||||||
const afterHtml = tempDiv.innerHTML;
|
const afterHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
before: beforeHtml,
|
before: beforeHtml,
|
||||||
after: afterHtml
|
after: afterHtml
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('getSplitContent: Split result:', {
|
||||||
|
cursorPosition,
|
||||||
|
contentLength: fullContent.length,
|
||||||
|
beforeContent: result.before,
|
||||||
|
beforeLength: result.before.length,
|
||||||
|
afterContent: result.after,
|
||||||
|
afterLength: result.after.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error splitting content:', error);
|
||||||
|
// Fallback: return all content as "before"
|
||||||
|
const fallbackResult = {
|
||||||
|
before: this.getContent(),
|
||||||
|
after: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('getSplitContent: Fallback result:', {
|
||||||
|
beforeContent: fallbackResult.before,
|
||||||
|
beforeLength: fallbackResult.before.length,
|
||||||
|
afterContent: fallbackResult.after,
|
||||||
|
afterLength: fallbackResult.after.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return fallbackResult;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMouseUp(_e: MouseEvent): void {
|
private handleMouseUp(_e: MouseEvent): void {
|
||||||
@ -570,4 +656,117 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs cursor position for debugging
|
||||||
|
*/
|
||||||
|
private logCursorPosition(eventType: string, event?: KeyboardEvent): void {
|
||||||
|
console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`);
|
||||||
|
|
||||||
|
// Get the actual active element considering shadow DOM
|
||||||
|
const activeElement = this.shadowRoot?.activeElement;
|
||||||
|
console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement);
|
||||||
|
|
||||||
|
// Only log if this block is focused
|
||||||
|
if (activeElement !== this.blockElement) {
|
||||||
|
console.log(`[CursorLog] Block not focused, skipping detailed logging`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selection info using the new utility that handles Shadow DOM
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log(`[${eventType}] No selection available`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!);
|
||||||
|
|
||||||
|
if (!isInThisBlock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position details
|
||||||
|
const details: any = {
|
||||||
|
event: eventType,
|
||||||
|
blockId: this.block.id,
|
||||||
|
blockType: this.block.type,
|
||||||
|
collapsed: selectionInfo.collapsed,
|
||||||
|
startContainer: {
|
||||||
|
nodeType: selectionInfo.startContainer.nodeType,
|
||||||
|
nodeName: selectionInfo.startContainer.nodeName,
|
||||||
|
textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...',
|
||||||
|
},
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add key info if it's a keyboard event
|
||||||
|
if (event) {
|
||||||
|
details.key = event.key;
|
||||||
|
details.shiftKey = event.shiftKey;
|
||||||
|
details.ctrlKey = event.ctrlKey;
|
||||||
|
details.metaKey = event.metaKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the actual cursor position in the text
|
||||||
|
if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textNode = selectionInfo.startContainer as Text;
|
||||||
|
const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || '';
|
||||||
|
const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || '';
|
||||||
|
|
||||||
|
details.cursorPosition = {
|
||||||
|
textBefore: textBefore.slice(-20), // Last 20 chars before cursor
|
||||||
|
textAfter: textAfter.slice(0, 20), // First 20 chars after cursor
|
||||||
|
totalLength: textNode.textContent?.length || 0,
|
||||||
|
offset: selectionInfo.startOffset
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're at boundaries
|
||||||
|
details.boundaries = {
|
||||||
|
atStart: this.isCursorAtStart(selectionInfo),
|
||||||
|
atEnd: this.isCursorAtEnd(selectionInfo)
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Cursor Position:', details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cursor is at the start of the block
|
||||||
|
*/
|
||||||
|
private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean {
|
||||||
|
if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false;
|
||||||
|
|
||||||
|
const firstNode = this.getFirstTextNode(this.blockElement);
|
||||||
|
return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cursor is at the end of the block
|
||||||
|
*/
|
||||||
|
private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean {
|
||||||
|
if (!selectionInfo.collapsed) return false;
|
||||||
|
|
||||||
|
const lastNode = this.getLastTextNode(this.blockElement);
|
||||||
|
if (!lastNode) return true;
|
||||||
|
|
||||||
|
return selectionInfo.endContainer === lastNode &&
|
||||||
|
selectionInfo.endOffset === (lastNode.textContent?.length || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last text node in the element
|
||||||
|
*/
|
||||||
|
private getLastTextNode(node: Node): Text | null {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node as Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||||
|
const lastText = this.getLastTextNode(node.childNodes[i]);
|
||||||
|
if (lastText) return lastText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,10 +1,12 @@
|
|||||||
export * from './wysiwyg.types.js';
|
export * from './wysiwyg.types.js';
|
||||||
export * from './wysiwyg.interfaces.js';
|
export * from './wysiwyg.interfaces.js';
|
||||||
|
export * from './wysiwyg.constants.js';
|
||||||
export * from './wysiwyg.styles.js';
|
export * from './wysiwyg.styles.js';
|
||||||
export * from './wysiwyg.converters.js';
|
export * from './wysiwyg.converters.js';
|
||||||
export * from './wysiwyg.shortcuts.js';
|
export * from './wysiwyg.shortcuts.js';
|
||||||
export * from './wysiwyg.blocks.js';
|
export * from './wysiwyg.blocks.js';
|
||||||
export * from './wysiwyg.formatting.js';
|
export * from './wysiwyg.formatting.js';
|
||||||
|
export * from './wysiwyg.selection.js';
|
||||||
export * from './wysiwyg.blockoperations.js';
|
export * from './wysiwyg.blockoperations.js';
|
||||||
export * from './wysiwyg.inputhandler.js';
|
export * from './wysiwyg.inputhandler.js';
|
||||||
export * from './wysiwyg.keyboardhandler.js';
|
export * from './wysiwyg.keyboardhandler.js';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
* We don't use lit template logic, but use static`` here to handle dom operations ourselves
|
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
|
||||||
* We try to have separated concerns in different classes
|
* We try to have separated concerns in different classes
|
||||||
* We try to have clean concise and managable code
|
* We try to have clean concise and managable code
|
||||||
* lets log whats happening, so if something goes wrong, we understand whats happening.
|
* lets log whats happening, so if something goes wrong, we understand whats happening.
|
||||||
|
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
@ -1,11 +1,12 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||||
|
|
||||||
export class WysiwygBlockOperations {
|
export class WysiwygBlockOperations {
|
||||||
private component: any; // Will be typed properly when imported
|
private component: IWysiwygComponent;
|
||||||
|
|
||||||
constructor(component: any) {
|
constructor(component: IWysiwygComponent) {
|
||||||
this.component = component;
|
this.component = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal file
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Shared constants for the WYSIWYG editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available programming languages for code blocks
|
||||||
|
*/
|
||||||
|
export const PROGRAMMING_LANGUAGES = [
|
||||||
|
'JavaScript',
|
||||||
|
'TypeScript',
|
||||||
|
'Python',
|
||||||
|
'Java',
|
||||||
|
'C++',
|
||||||
|
'C#',
|
||||||
|
'Go',
|
||||||
|
'Rust',
|
||||||
|
'HTML',
|
||||||
|
'CSS',
|
||||||
|
'SQL',
|
||||||
|
'Shell',
|
||||||
|
'JSON',
|
||||||
|
'YAML',
|
||||||
|
'Markdown',
|
||||||
|
'Plain Text'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];
|
@ -1,7 +1,8 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||||
|
|
||||||
export class WysiwygDragDropHandler {
|
export class WysiwygDragDropHandler {
|
||||||
private component: any;
|
private component: IWysiwygComponent;
|
||||||
private draggedBlockId: string | null = null;
|
private draggedBlockId: string | null = null;
|
||||||
private dragOverBlockId: string | null = null;
|
private dragOverBlockId: string | null = null;
|
||||||
private dragOverPosition: 'before' | 'after' | null = null;
|
private dragOverPosition: 'before' | 'after' | null = null;
|
||||||
@ -13,7 +14,7 @@ export class WysiwygDragDropHandler {
|
|||||||
private lastUpdateTime: number = 0;
|
private lastUpdateTime: number = 0;
|
||||||
private updateThrottle: number = 80; // milliseconds
|
private updateThrottle: number = 80; // milliseconds
|
||||||
|
|
||||||
constructor(component: any) {
|
constructor(component: IWysiwygComponent) {
|
||||||
this.component = component;
|
this.component = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||||
|
|
||||||
export interface IFormatButton {
|
export interface IFormatButton {
|
||||||
command: string;
|
command: string;
|
||||||
@ -143,20 +144,20 @@ export class WysiwygFormatting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
|
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null {
|
||||||
// Try shadow root selection first, then window
|
// Get selection info using the new utility that handles Shadow DOM
|
||||||
let selection = shadowRoot && (shadowRoot as any).getSelection ? (shadowRoot as any).getSelection() : null;
|
const selectionInfo = shadowRoot
|
||||||
if (!selection || selection.rangeCount === 0) {
|
? WysiwygSelection.getSelectionInfo(shadowRoot)
|
||||||
selection = window.getSelection();
|
: WysiwygSelection.getSelectionInfo();
|
||||||
}
|
|
||||||
|
|
||||||
console.log('getSelectionCoordinates - selection:', selection);
|
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
|
||||||
|
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selectionInfo) {
|
||||||
console.log('No selection or no ranges');
|
console.log('No selection info available');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
// Create a range from the selection info to get bounding rect
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
console.log('Range rect:', rect);
|
console.log('Range rect:', rect);
|
||||||
@ -174,57 +175,4 @@ export class WysiwygFormatting {
|
|||||||
console.log('Returning coords:', coords);
|
console.log('Returning coords:', coords);
|
||||||
return coords;
|
return coords;
|
||||||
}
|
}
|
||||||
|
|
||||||
static isFormattingApplied(command: string): boolean {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static hasSelection(): boolean {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection || selection.rangeCount === 0) {
|
|
||||||
console.log('No selection or no ranges');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we actually have selected text (not just collapsed cursor)
|
|
||||||
const selectedText = selection.toString();
|
|
||||||
if (!selectedText || selectedText.length === 0) {
|
|
||||||
console.log('No text selected');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSelectedText(): string {
|
|
||||||
const selection = window.getSelection();
|
|
||||||
return selection ? selection.toString() : '';
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,14 +1,15 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||||||
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
|
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
|
||||||
|
|
||||||
export class WysiwygInputHandler {
|
export class WysiwygInputHandler {
|
||||||
private component: any;
|
private component: IWysiwygComponent;
|
||||||
private saveTimeout: any = null;
|
private saveTimeout: any = null;
|
||||||
|
|
||||||
constructor(component: any) {
|
constructor(component: IWysiwygComponent) {
|
||||||
this.component = component;
|
this.component = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,6 +175,11 @@ export class WysiwygInputHandler {
|
|||||||
if (this.component.editorContentRef) {
|
if (this.component.editorContentRef) {
|
||||||
this.component.updateBlockElement(block.id);
|
this.component.updateBlockElement(block.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Focus the code block
|
||||||
|
setTimeout(async () => {
|
||||||
|
await blockOps.focusBlock(block.id, 'start');
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
block.type = detectedType.type;
|
block.type = detectedType.type;
|
||||||
@ -186,6 +192,11 @@ export class WysiwygInputHandler {
|
|||||||
if (this.component.editorContentRef) {
|
if (this.component.editorContentRef) {
|
||||||
this.component.updateBlockElement(block.id);
|
this.component.updateBlockElement(block.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Focus the transformed block
|
||||||
|
setTimeout(async () => {
|
||||||
|
await blockOps.focusBlock(block.id, 'start');
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,6 +11,11 @@ export interface IWysiwygComponent {
|
|||||||
blocks: IBlock[];
|
blocks: IBlock[];
|
||||||
selectedBlockId: string | null;
|
selectedBlockId: string | null;
|
||||||
shadowRoot: ShadowRoot | null;
|
shadowRoot: ShadowRoot | null;
|
||||||
|
editorContentRef: HTMLDivElement;
|
||||||
|
draggedBlockId: string | null;
|
||||||
|
dragOverBlockId: string | null;
|
||||||
|
dragOverPosition: 'before' | 'after' | null;
|
||||||
|
isComposing: boolean;
|
||||||
|
|
||||||
// Menus
|
// Menus
|
||||||
slashMenu: DeesSlashMenu;
|
slashMenu: DeesSlashMenu;
|
||||||
@ -18,12 +23,16 @@ export interface IWysiwygComponent {
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
updateValue(): void;
|
updateValue(): void;
|
||||||
requestUpdate(): Promise<void>;
|
requestUpdate(): void;
|
||||||
updateComplete: Promise<boolean>;
|
updateComplete: Promise<boolean>;
|
||||||
insertBlock(type: string): Promise<void>;
|
insertBlock(type: string): Promise<void>;
|
||||||
closeSlashMenu(clearSlash?: boolean): void;
|
closeSlashMenu(clearSlash?: boolean): void;
|
||||||
applyFormat(command: string): Promise<void>;
|
applyFormat(command: string): Promise<void>;
|
||||||
handleSlashMenuKeyboard(e: KeyboardEvent): void;
|
handleSlashMenuKeyboard(e: KeyboardEvent): void;
|
||||||
|
createBlockElement(block: IBlock): HTMLElement;
|
||||||
|
updateBlockElement(blockId: string): void;
|
||||||
|
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
||||||
|
renderBlocksProgrammatically(): void;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
blockOperations: IBlockOperations;
|
blockOperations: IBlockOperations;
|
||||||
@ -44,7 +53,6 @@ export interface IBlockOperations {
|
|||||||
moveBlock(blockId: string, targetIndex: number): void;
|
moveBlock(blockId: string, targetIndex: number): void;
|
||||||
getPreviousBlock(blockId: string): IBlock | null;
|
getPreviousBlock(blockId: string): IBlock | null;
|
||||||
getNextBlock(blockId: string): IBlock | null;
|
getNextBlock(blockId: string): IBlock | null;
|
||||||
splitBlock(blockId: string, splitPosition: number): Promise<IBlock>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||||
|
|
||||||
export class WysiwygKeyboardHandler {
|
export class WysiwygKeyboardHandler {
|
||||||
private component: any;
|
private component: IWysiwygComponent;
|
||||||
|
|
||||||
constructor(component: any) {
|
constructor(component: IWysiwygComponent) {
|
||||||
this.component = component;
|
this.component = component;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,34 +133,59 @@ export class WysiwygKeyboardHandler {
|
|||||||
// Split content at cursor position
|
// Split content at cursor position
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Get the block component
|
console.log('Enter key pressed in block:', {
|
||||||
const target = e.target as HTMLElement;
|
blockId: block.id,
|
||||||
const blockWrapper = target.closest('.block-wrapper');
|
blockType: block.type,
|
||||||
|
blockContent: block.content,
|
||||||
|
blockContentLength: block.content?.length || 0,
|
||||||
|
eventTarget: e.target,
|
||||||
|
eventTargetTagName: (e.target as HTMLElement).tagName
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the block component - need to search in the wysiwyg component's shadow DOM
|
||||||
|
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||||
|
console.log('Found block wrapper:', blockWrapper);
|
||||||
|
|
||||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||||
|
console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent);
|
||||||
|
|
||||||
if (blockComponent && blockComponent.getSplitContent) {
|
if (blockComponent && blockComponent.getSplitContent) {
|
||||||
|
console.log('Calling getSplitContent...');
|
||||||
const splitContent = blockComponent.getSplitContent();
|
const splitContent = blockComponent.getSplitContent();
|
||||||
|
|
||||||
|
console.log('Enter key split content result:', {
|
||||||
|
hasSplitContent: !!splitContent,
|
||||||
|
beforeLength: splitContent?.before?.length || 0,
|
||||||
|
afterLength: splitContent?.after?.length || 0,
|
||||||
|
splitContent
|
||||||
|
});
|
||||||
|
|
||||||
if (splitContent) {
|
if (splitContent) {
|
||||||
|
console.log('Updating current block with before content...');
|
||||||
// Update current block with content before cursor
|
// Update current block with content before cursor
|
||||||
blockComponent.setContent(splitContent.before);
|
blockComponent.setContent(splitContent.before);
|
||||||
block.content = splitContent.before;
|
block.content = splitContent.before;
|
||||||
|
|
||||||
|
console.log('Creating new block with after content...');
|
||||||
// Create new block with content after cursor
|
// Create new block with content after cursor
|
||||||
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
||||||
|
|
||||||
|
console.log('Inserting new block...');
|
||||||
// Insert the new block
|
// Insert the new block
|
||||||
await blockOps.insertBlockAfter(block, newBlock);
|
await blockOps.insertBlockAfter(block, newBlock);
|
||||||
|
|
||||||
// Update the value after both blocks are set
|
// Update the value after both blocks are set
|
||||||
this.component.updateValue();
|
this.component.updateValue();
|
||||||
|
console.log('Enter key handling complete');
|
||||||
} else {
|
} else {
|
||||||
// Fallback - just create empty block
|
// Fallback - just create empty block
|
||||||
|
console.log('No split content returned, creating empty block');
|
||||||
const newBlock = blockOps.createBlock();
|
const newBlock = blockOps.createBlock();
|
||||||
await blockOps.insertBlockAfter(block, newBlock);
|
await blockOps.insertBlockAfter(block, newBlock);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No block component or method, just create empty block
|
// No block component or method, just create empty block
|
||||||
|
console.log('No getSplitContent method, creating empty block');
|
||||||
const newBlock = blockOps.createBlock();
|
const newBlock = blockOps.createBlock();
|
||||||
await blockOps.insertBlockAfter(block, newBlock);
|
await blockOps.insertBlockAfter(block, newBlock);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { html, type TemplateResult } from '@design.estate/dees-element';
|
|||||||
import { DeesModal } from '../dees-modal.js';
|
import { DeesModal } from '../dees-modal.js';
|
||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||||
|
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
|
||||||
|
|
||||||
export class WysiwygModalManager {
|
export class WysiwygModalManager {
|
||||||
/**
|
/**
|
||||||
@ -207,11 +208,7 @@ export class WysiwygModalManager {
|
|||||||
* Gets available programming languages
|
* Gets available programming languages
|
||||||
*/
|
*/
|
||||||
private static getLanguages(): string[] {
|
private static getLanguages(): string[] {
|
||||||
return [
|
return [...PROGRAMMING_LANGUAGES];
|
||||||
'JavaScript', 'TypeScript', 'Python', 'Java',
|
|
||||||
'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS',
|
|
||||||
'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
157
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal file
157
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Utilities for handling selection across Shadow DOM boundaries
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SelectionInfo {
|
||||||
|
startContainer: Node;
|
||||||
|
startOffset: number;
|
||||||
|
endContainer: Node;
|
||||||
|
endOffset: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WysiwygSelection {
|
||||||
|
/**
|
||||||
|
* Gets selection info that works across Shadow DOM boundaries
|
||||||
|
* @param shadowRoots - Shadow roots to include in the selection search
|
||||||
|
*/
|
||||||
|
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return null;
|
||||||
|
|
||||||
|
// Try using getComposedRanges if available (better Shadow DOM support)
|
||||||
|
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
|
||||||
|
try {
|
||||||
|
const ranges = selection.getComposedRanges(...shadowRoots);
|
||||||
|
if (ranges.length > 0) {
|
||||||
|
const range = ranges[0];
|
||||||
|
return {
|
||||||
|
startContainer: range.startContainer,
|
||||||
|
startOffset: range.startOffset,
|
||||||
|
endContainer: range.endContainer,
|
||||||
|
endOffset: range.endOffset,
|
||||||
|
collapsed: range.collapsed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to traditional selection API
|
||||||
|
if (selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
return {
|
||||||
|
startContainer: range.startContainer,
|
||||||
|
startOffset: range.startOffset,
|
||||||
|
endContainer: range.endContainer,
|
||||||
|
endOffset: range.endOffset,
|
||||||
|
collapsed: range.collapsed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a selection is within a specific element (considering Shadow DOM)
|
||||||
|
*/
|
||||||
|
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
|
||||||
|
const selectionInfo = shadowRoot
|
||||||
|
? this.getSelectionInfo(shadowRoot)
|
||||||
|
: this.getSelectionInfo();
|
||||||
|
|
||||||
|
if (!selectionInfo) return false;
|
||||||
|
|
||||||
|
// Check if the selection's common ancestor is within the element
|
||||||
|
return element.contains(selectionInfo.startContainer) ||
|
||||||
|
element.contains(selectionInfo.endContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the selected text across Shadow DOM boundaries
|
||||||
|
*/
|
||||||
|
static getSelectedText(): string {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
return selection ? selection.toString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a range from selection info
|
||||||
|
*/
|
||||||
|
static createRangeFromInfo(info: SelectionInfo): Range {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(info.startContainer, info.startOffset);
|
||||||
|
range.setEnd(info.endContainer, info.endOffset);
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets selection from a range (works with Shadow DOM)
|
||||||
|
*/
|
||||||
|
static setSelectionFromRange(range: Range): void {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets cursor position relative to a specific element
|
||||||
|
*/
|
||||||
|
static getCursorPositionInElement(element: Element, shadowRoot?: ShadowRoot): number | null {
|
||||||
|
const selectionInfo = shadowRoot
|
||||||
|
? this.getSelectionInfo(shadowRoot)
|
||||||
|
: this.getSelectionInfo();
|
||||||
|
|
||||||
|
if (!selectionInfo || !selectionInfo.collapsed) return null;
|
||||||
|
|
||||||
|
// Create a range from start of element to cursor position
|
||||||
|
try {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
return range.toString().length;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get cursor position:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets cursor position in an element
|
||||||
|
*/
|
||||||
|
static setCursorPosition(element: Element, position: number): void {
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
element,
|
||||||
|
NodeFilter.SHOW_TEXT,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentPosition = 0;
|
||||||
|
let targetNode: Text | null = null;
|
||||||
|
let targetOffset = 0;
|
||||||
|
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
const node = walker.currentNode as Text;
|
||||||
|
const nodeLength = node.textContent?.length || 0;
|
||||||
|
|
||||||
|
if (currentPosition + nodeLength >= position) {
|
||||||
|
targetNode = node;
|
||||||
|
targetOffset = position - currentPosition;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPosition += nodeLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetNode) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(targetNode, targetOffset);
|
||||||
|
range.collapse(true);
|
||||||
|
this.setSelectionFromRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user