feat(wysiwyg): implement backspace
This commit is contained in:
@ -23,6 +23,7 @@ import {
|
||||
WysiwygKeyboardHandler,
|
||||
WysiwygDragDropHandler,
|
||||
WysiwygModalManager,
|
||||
WysiwygHistory,
|
||||
DeesSlashMenu,
|
||||
DeesFormattingMenu
|
||||
} from './index.js';
|
||||
@ -89,6 +90,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
private inputHandler: WysiwygInputHandler;
|
||||
private keyboardHandler: WysiwygKeyboardHandler;
|
||||
private dragDropHandler: WysiwygDragDropHandler;
|
||||
private history: WysiwygHistory;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
@ -103,6 +105,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
this.inputHandler = new WysiwygInputHandler(this);
|
||||
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
||||
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
||||
this.history = new WysiwygHistory();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
@ -143,6 +146,27 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}
|
||||
});
|
||||
|
||||
// Add global keyboard listener for undo/redo
|
||||
this.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// Check if the event is from within our editor
|
||||
const target = e.target as HTMLElement;
|
||||
if (!this.contains(target) && !this.shadowRoot?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle undo/redo
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
this.undo();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
this.redo();
|
||||
}
|
||||
});
|
||||
|
||||
// Save initial state to history
|
||||
this.history.saveState(this.blocks, this.selectedBlockId);
|
||||
|
||||
// Render blocks programmatically
|
||||
this.renderBlocksProgrammatically();
|
||||
}
|
||||
@ -516,6 +540,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
||||
}
|
||||
this.changeSubject.next(this.value);
|
||||
|
||||
// Save to history (debounced)
|
||||
this.saveToHistory(true);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
@ -879,4 +906,88 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the last action
|
||||
*/
|
||||
private undo(): void {
|
||||
console.log('Undo triggered');
|
||||
const state = this.history.undo();
|
||||
if (state) {
|
||||
this.restoreState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo the next action
|
||||
*/
|
||||
private redo(): void {
|
||||
console.log('Redo triggered');
|
||||
const state = this.history.redo();
|
||||
if (state) {
|
||||
this.restoreState(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore editor state from history
|
||||
*/
|
||||
private restoreState(state: { blocks: IBlock[]; selectedBlockId: string | null; cursorPosition?: { blockId: string; offset: number } }): void {
|
||||
// Update blocks
|
||||
this.blocks = state.blocks;
|
||||
this.selectedBlockId = state.selectedBlockId;
|
||||
|
||||
// Re-render blocks
|
||||
this.renderBlocksProgrammatically();
|
||||
|
||||
// Restore cursor position if available
|
||||
if (state.cursorPosition) {
|
||||
setTimeout(() => {
|
||||
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${state.cursorPosition!.blockId}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
blockComponent.focusWithCursor(state.cursorPosition!.offset);
|
||||
}
|
||||
}, 50);
|
||||
} else if (state.selectedBlockId) {
|
||||
// Just focus the selected block
|
||||
setTimeout(() => {
|
||||
this.blockOperations.focusBlock(state.selectedBlockId!);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Update value
|
||||
this.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to history with cursor position
|
||||
*/
|
||||
public saveToHistory(debounce: boolean = true): void {
|
||||
// Get current cursor position if a block is focused
|
||||
let cursorPosition: { blockId: string; offset: number } | undefined;
|
||||
|
||||
if (this.selectedBlockId) {
|
||||
const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${this.selectedBlockId}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent && typeof blockComponent.getCursorPosition === 'function') {
|
||||
const editableElement = blockComponent.shadowRoot?.querySelector('.block') as HTMLElement;
|
||||
if (editableElement) {
|
||||
const offset = blockComponent.getCursorPosition(editableElement);
|
||||
if (offset !== null) {
|
||||
cursorPosition = {
|
||||
blockId: this.selectedBlockId,
|
||||
offset
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debounce) {
|
||||
this.history.saveState(this.blocks, this.selectedBlockId, cursorPosition);
|
||||
} else {
|
||||
this.history.saveCheckpoint(this.blocks, this.selectedBlockId, cursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
@ -506,7 +506,7 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
/**
|
||||
* Get cursor position in the editable element
|
||||
*/
|
||||
private getCursorPosition(element: HTMLElement): number | null {
|
||||
public getCursorPosition(element: HTMLElement): number | null {
|
||||
// Get parent wysiwyg component's shadow root
|
||||
const parentComponent = this.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
@ -765,8 +765,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
||||
cursorPos,
|
||||
beforeHtml,
|
||||
beforeLength: beforeHtml.length,
|
||||
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||
afterHtml,
|
||||
afterLength: afterHtml.length
|
||||
afterLength: afterHtml.length,
|
||||
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -12,6 +12,7 @@ export * from './wysiwyg.inputhandler.js';
|
||||
export * from './wysiwyg.keyboardhandler.js';
|
||||
export * from './wysiwyg.dragdrophandler.js';
|
||||
export * from './wysiwyg.modalmanager.js';
|
||||
export * from './wysiwyg.history.js';
|
||||
export * from './dees-wysiwyg-block.js';
|
||||
export * from './dees-slash-menu.js';
|
||||
export * from './dees-formatting-menu.js';
|
@ -59,6 +59,9 @@ export class WysiwygBlockOperations {
|
||||
* Removes a block by its ID
|
||||
*/
|
||||
removeBlock(blockId: string): void {
|
||||
// Save checkpoint before deletion
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
||||
|
||||
// Remove the block element programmatically if we have the editor
|
||||
@ -120,6 +123,9 @@ export class WysiwygBlockOperations {
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
// Save checkpoint before transformation
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
block.type = newType;
|
||||
block.content = '';
|
||||
if (metadata) {
|
||||
|
167
ts_web/elements/wysiwyg/wysiwyg.history.ts
Normal file
167
ts_web/elements/wysiwyg/wysiwyg.history.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export interface IHistoryState {
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
cursorPosition?: {
|
||||
blockId: string;
|
||||
offset: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class WysiwygHistory {
|
||||
private history: IHistoryState[] = [];
|
||||
private currentIndex: number = -1;
|
||||
private maxHistorySize: number = 50;
|
||||
private lastSaveTime: number = 0;
|
||||
private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots
|
||||
|
||||
constructor() {
|
||||
// Initialize with empty state
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to history
|
||||
*/
|
||||
saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Debounce rapid changes (like typing)
|
||||
if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) {
|
||||
// Update the current state instead of creating a new one
|
||||
this.history[this.currentIndex] = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any states after current index (when we save after undoing)
|
||||
if (this.currentIndex < this.history.length - 1) {
|
||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
// Add new state
|
||||
const newState: IHistoryState = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
this.history.push(newState);
|
||||
this.currentIndex++;
|
||||
|
||||
// Limit history size
|
||||
if (this.history.length > this.maxHistorySize) {
|
||||
this.history.shift();
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
this.lastSaveTime = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save a checkpoint (useful for operations like block deletion)
|
||||
*/
|
||||
saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
this.lastSaveTime = 0; // Reset debounce
|
||||
this.saveState(blocks, selectedBlockId, cursorPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo to previous state
|
||||
*/
|
||||
undo(): IHistoryState | null {
|
||||
if (!this.canUndo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex--;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo to next state
|
||||
*/
|
||||
redo(): IHistoryState | null {
|
||||
if (!this.canRedo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo is available
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.currentIndex > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo is available
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.currentIndex < this.history.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getCurrentState(): IHistoryState | null {
|
||||
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history
|
||||
*/
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone blocks
|
||||
*/
|
||||
private cloneBlocks(blocks: IBlock[]): IBlock[] {
|
||||
return blocks.map(block => ({
|
||||
...block,
|
||||
metadata: block.metadata ? { ...block.metadata } : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a history state
|
||||
*/
|
||||
private cloneState(state: IHistoryState): IHistoryState {
|
||||
return {
|
||||
blocks: this.cloneBlocks(state.blocks),
|
||||
selectedBlockId: state.selectedBlockId,
|
||||
cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined,
|
||||
timestamp: state.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history info for debugging
|
||||
*/
|
||||
getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } {
|
||||
return {
|
||||
size: this.history.length,
|
||||
currentIndex: this.currentIndex,
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo()
|
||||
};
|
||||
}
|
||||
}
|
@ -33,6 +33,7 @@ export interface IWysiwygComponent {
|
||||
updateBlockElement(blockId: string): void;
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
||||
renderBlocksProgrammatically(): void;
|
||||
saveToHistory(debounce?: boolean): void;
|
||||
|
||||
// Handlers
|
||||
blockOperations: IBlockOperations;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
export class WysiwygKeyboardHandler {
|
||||
private component: IWysiwygComponent;
|
||||
@ -219,9 +220,94 @@ export class WysiwygKeyboardHandler {
|
||||
* Handles Backspace key
|
||||
*/
|
||||
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
if (block.content === '' && this.component.blocks.length > 1) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// Get the block component to check cursor position
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get cursor position
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
if (cursorPos === 0) {
|
||||
e.preventDefault();
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
console.log('Backspace at start: Merging with previous block');
|
||||
|
||||
// Save checkpoint for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Special handling for different block types
|
||||
if (prevBlock.type === 'code' && block.type !== 'code') {
|
||||
// Can't merge non-code into code block, just remove empty block
|
||||
if (block.content === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === 'code' && prevBlock.type !== 'code') {
|
||||
// Can't merge code into non-code block
|
||||
if (block.content === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the content of both blocks
|
||||
const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`);
|
||||
const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
const prevContent = prevBlockComponent?.getContent() || prevBlock.content || '';
|
||||
const currentContent = blockComponent.getContent() || block.content || '';
|
||||
|
||||
// Merge content
|
||||
let mergedContent = '';
|
||||
if (prevBlock.type === 'code' && block.type === 'code') {
|
||||
// For code blocks, join with newline
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else if (prevBlock.type === 'list' && block.type === 'list') {
|
||||
// For lists, combine the list items
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else {
|
||||
// For other blocks, join with space if both have content
|
||||
mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent;
|
||||
}
|
||||
|
||||
// Store cursor position (where the merge point is)
|
||||
const mergePoint = prevContent.length;
|
||||
|
||||
// Update previous block with merged content
|
||||
blockOps.updateBlockContent(prevBlock.id, mergedContent);
|
||||
if (prevBlockComponent) {
|
||||
prevBlockComponent.setContent(mergedContent);
|
||||
}
|
||||
|
||||
// Remove current block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus previous block at merge point
|
||||
await blockOps.focusBlock(prevBlock.id, mergePoint);
|
||||
}
|
||||
} else if (block.content === '' && this.component.blocks.length > 1) {
|
||||
// Empty block - just remove it
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
@ -232,84 +318,85 @@ export class WysiwygKeyboardHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal backspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowUp key - navigate to previous block if at beginning
|
||||
* Handles ArrowUp key - navigate to previous block if at beginning or first line
|
||||
*/
|
||||
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
if (isAtStart) {
|
||||
const firstNode = target.firstChild;
|
||||
const isReallyAtStart = !firstNode ||
|
||||
(range.startContainer === firstNode && range.startOffset === 0) ||
|
||||
(range.startContainer === target && range.startOffset === 0);
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
if (isReallyAtStart) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
// Check if we're on the first line
|
||||
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
|
||||
console.log('ArrowUp: On first line, navigating to previous block');
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
console.log('ArrowUp: Focusing previous block:', prevBlock.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowDown key - navigate to next block if at end
|
||||
* Handles ArrowDown key - navigate to next block if at end or last line
|
||||
*/
|
||||
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
const lastNode = target.lastChild;
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
// For different block types, check if we're at the end
|
||||
let isAtEnd = false;
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
if (!lastNode) {
|
||||
// Empty block
|
||||
isAtEnd = true;
|
||||
} else if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length;
|
||||
} else if (block.type === 'list') {
|
||||
// For lists, check if we're in the last item at the end
|
||||
const lastLi = target.querySelector('li:last-child');
|
||||
if (lastLi) {
|
||||
const lastTextNode = this.getLastTextNode(lastLi);
|
||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
} else {
|
||||
// For other HTML content
|
||||
const lastTextNode = this.getLastTextNode(target);
|
||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
|
||||
if (isAtEnd) {
|
||||
// Check if we're on the last line
|
||||
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
|
||||
console.log('ArrowDown: On last line, navigating to next block');
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock && nextBlock.type !== 'divider') {
|
||||
console.log('ArrowDown: Focusing next block:', nextBlock.id);
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
@ -332,29 +419,38 @@ export class WysiwygKeyboardHandler {
|
||||
* Handles ArrowLeft key - navigate to previous block if at beginning
|
||||
*/
|
||||
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Check if cursor is at the very beginning (collapsed and at offset 0)
|
||||
if (range.collapsed && range.startOffset === 0) {
|
||||
const target = e.target as HTMLElement;
|
||||
const firstNode = target.firstChild;
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
// Verify we're really at the start
|
||||
const isAtStart = !firstNode ||
|
||||
(range.startContainer === firstNode) ||
|
||||
(range.startContainer === target);
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
if (isAtStart) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
// Check if cursor is at the beginning of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
console.log('ArrowLeft: Cursor position:', cursorPos, 'in block:', block.id);
|
||||
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
if (cursorPos === 0) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
console.log('ArrowLeft: At start, previous block:', prevBlock?.id);
|
||||
|
||||
if (prevBlock && prevBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal left arrow navigation
|
||||
@ -364,40 +460,37 @@ export class WysiwygKeyboardHandler {
|
||||
* Handles ArrowRight key - navigate to next block if at end
|
||||
*/
|
||||
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = e.target as HTMLElement;
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Check if cursor is at the very end
|
||||
if (range.collapsed) {
|
||||
const textLength = target.textContent?.length || 0;
|
||||
let isAtEnd = false;
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
if (textLength === 0) {
|
||||
// Empty block
|
||||
isAtEnd = true;
|
||||
} else if (range.endContainer.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = range.endContainer as Text;
|
||||
isAtEnd = range.endOffset === textNode.textContent?.length;
|
||||
} else {
|
||||
// Check if we're at the end of the last text node
|
||||
const lastTextNode = this.getLastTextNode(target);
|
||||
if (lastTextNode) {
|
||||
isAtEnd = range.endContainer === lastTextNode &&
|
||||
range.endOffset === lastTextNode.textContent?.length;
|
||||
}
|
||||
}
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
if (isAtEnd) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
// Check if cursor is at the end of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
const textLength = target.textContent?.length || 0;
|
||||
|
||||
if (nextBlock && nextBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
}
|
||||
if (cursorPos === textLength) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock && nextBlock.type !== 'divider') {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal right arrow navigation
|
||||
@ -407,4 +500,78 @@ export class WysiwygKeyboardHandler {
|
||||
* Handles slash menu keyboard navigation
|
||||
* Note: This is now handled by the component directly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if cursor is on the first line of a block
|
||||
*/
|
||||
private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the top position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the top (within 5px tolerance for line height variations)
|
||||
const isNearTop = rect.top - containerRect.top < 5;
|
||||
|
||||
// For single-line content, also check if we're at the beginning
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots);
|
||||
return cursorPos === 0;
|
||||
}
|
||||
|
||||
return isNearTop;
|
||||
} catch (e) {
|
||||
console.warn('Error checking first line:', e);
|
||||
// Fallback to position-based check
|
||||
const cursorPos = selectionInfo.startOffset;
|
||||
return cursorPos === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cursor is on the last line of a block
|
||||
*/
|
||||
private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the bottom position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the bottom (within 5px tolerance for line height variations)
|
||||
const isNearBottom = containerRect.bottom - rect.bottom < 5;
|
||||
|
||||
// For single-line content, also check if we're at the end
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
|
||||
return isNearBottom;
|
||||
} catch (e) {
|
||||
console.warn('Error checking last line:', e);
|
||||
// Fallback to position-based check
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user