feat(wysiwyg): implement backspace
This commit is contained in:
@ -23,6 +23,7 @@ import {
|
|||||||
WysiwygKeyboardHandler,
|
WysiwygKeyboardHandler,
|
||||||
WysiwygDragDropHandler,
|
WysiwygDragDropHandler,
|
||||||
WysiwygModalManager,
|
WysiwygModalManager,
|
||||||
|
WysiwygHistory,
|
||||||
DeesSlashMenu,
|
DeesSlashMenu,
|
||||||
DeesFormattingMenu
|
DeesFormattingMenu
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
@ -89,6 +90,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
private inputHandler: WysiwygInputHandler;
|
private inputHandler: WysiwygInputHandler;
|
||||||
private keyboardHandler: WysiwygKeyboardHandler;
|
private keyboardHandler: WysiwygKeyboardHandler;
|
||||||
private dragDropHandler: WysiwygDragDropHandler;
|
private dragDropHandler: WysiwygDragDropHandler;
|
||||||
|
private history: WysiwygHistory;
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
...DeesInputBase.baseStyles,
|
...DeesInputBase.baseStyles,
|
||||||
@ -103,6 +105,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.inputHandler = new WysiwygInputHandler(this);
|
this.inputHandler = new WysiwygInputHandler(this);
|
||||||
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
this.keyboardHandler = new WysiwygKeyboardHandler(this);
|
||||||
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
this.dragDropHandler = new WysiwygDragDropHandler(this);
|
||||||
|
this.history = new WysiwygHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
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
|
// Render blocks programmatically
|
||||||
this.renderBlocksProgrammatically();
|
this.renderBlocksProgrammatically();
|
||||||
}
|
}
|
||||||
@ -516,6 +540,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
|
||||||
}
|
}
|
||||||
this.changeSubject.next(this.value);
|
this.changeSubject.next(this.value);
|
||||||
|
|
||||||
|
// Save to history (debounced)
|
||||||
|
this.saveToHistory(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getValue(): string {
|
public getValue(): string {
|
||||||
@ -879,4 +906,88 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
}, 100);
|
}, 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
|
* 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
|
// Get parent wysiwyg component's shadow root
|
||||||
const parentComponent = this.closest('dees-input-wysiwyg');
|
const parentComponent = this.closest('dees-input-wysiwyg');
|
||||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
@ -765,8 +765,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
cursorPos,
|
cursorPos,
|
||||||
beforeHtml,
|
beforeHtml,
|
||||||
beforeLength: beforeHtml.length,
|
beforeLength: beforeHtml.length,
|
||||||
|
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||||
afterHtml,
|
afterHtml,
|
||||||
afterLength: afterHtml.length
|
afterLength: afterHtml.length,
|
||||||
|
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -12,6 +12,7 @@ export * from './wysiwyg.inputhandler.js';
|
|||||||
export * from './wysiwyg.keyboardhandler.js';
|
export * from './wysiwyg.keyboardhandler.js';
|
||||||
export * from './wysiwyg.dragdrophandler.js';
|
export * from './wysiwyg.dragdrophandler.js';
|
||||||
export * from './wysiwyg.modalmanager.js';
|
export * from './wysiwyg.modalmanager.js';
|
||||||
|
export * from './wysiwyg.history.js';
|
||||||
export * from './dees-wysiwyg-block.js';
|
export * from './dees-wysiwyg-block.js';
|
||||||
export * from './dees-slash-menu.js';
|
export * from './dees-slash-menu.js';
|
||||||
export * from './dees-formatting-menu.js';
|
export * from './dees-formatting-menu.js';
|
@ -59,6 +59,9 @@ export class WysiwygBlockOperations {
|
|||||||
* Removes a block by its ID
|
* Removes a block by its ID
|
||||||
*/
|
*/
|
||||||
removeBlock(blockId: string): void {
|
removeBlock(blockId: string): void {
|
||||||
|
// Save checkpoint before deletion
|
||||||
|
this.component.saveToHistory(false);
|
||||||
|
|
||||||
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
||||||
|
|
||||||
// Remove the block element programmatically if we have the editor
|
// 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 {
|
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
|
||||||
const block = this.findBlock(blockId);
|
const block = this.findBlock(blockId);
|
||||||
if (block) {
|
if (block) {
|
||||||
|
// Save checkpoint before transformation
|
||||||
|
this.component.saveToHistory(false);
|
||||||
|
|
||||||
block.type = newType;
|
block.type = newType;
|
||||||
block.content = '';
|
block.content = '';
|
||||||
if (metadata) {
|
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;
|
updateBlockElement(blockId: string): void;
|
||||||
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
||||||
renderBlocksProgrammatically(): void;
|
renderBlocksProgrammatically(): void;
|
||||||
|
saveToHistory(debounce?: boolean): void;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
blockOperations: IBlockOperations;
|
blockOperations: IBlockOperations;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||||
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||||
|
|
||||||
export class WysiwygKeyboardHandler {
|
export class WysiwygKeyboardHandler {
|
||||||
private component: IWysiwygComponent;
|
private component: IWysiwygComponent;
|
||||||
@ -219,9 +220,94 @@ export class WysiwygKeyboardHandler {
|
|||||||
* Handles Backspace key
|
* Handles Backspace key
|
||||||
*/
|
*/
|
||||||
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
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();
|
e.preventDefault();
|
||||||
const blockOps = this.component.blockOperations;
|
|
||||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||||
|
|
||||||
if (prevBlock) {
|
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> {
|
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const selection = window.getSelection();
|
// Get the block component from the wysiwyg component's shadow DOM
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
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 = e.target as HTMLElement;
|
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
|
// Get selection info with proper shadow DOM support
|
||||||
const isAtStart = range.startOffset === 0 && range.endOffset === 0;
|
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||||
|
shadowRoots.push(blockComponent.shadowRoot);
|
||||||
|
|
||||||
if (isAtStart) {
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
const firstNode = target.firstChild;
|
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||||
const isReallyAtStart = !firstNode ||
|
|
||||||
(range.startContainer === firstNode && range.startOffset === 0) ||
|
|
||||||
(range.startContainer === target && range.startOffset === 0);
|
|
||||||
|
|
||||||
if (isReallyAtStart) {
|
// Check if we're on the first line
|
||||||
e.preventDefault();
|
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
|
||||||
const blockOps = this.component.blockOperations;
|
console.log('ArrowUp: On first line, navigating to previous block');
|
||||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
e.preventDefault();
|
||||||
|
const blockOps = this.component.blockOperations;
|
||||||
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||||
|
|
||||||
if (prevBlock && prevBlock.type !== 'divider') {
|
if (prevBlock && prevBlock.type !== 'divider') {
|
||||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
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> {
|
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const selection = window.getSelection();
|
// Get the block component from the wysiwyg component's shadow DOM
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
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 = e.target as HTMLElement;
|
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
|
// Get selection info with proper shadow DOM support
|
||||||
const lastNode = target.lastChild;
|
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
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
let isAtEnd = false;
|
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||||
|
|
||||||
if (!lastNode) {
|
// Check if we're on the last line
|
||||||
// Empty block
|
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
|
||||||
isAtEnd = true;
|
console.log('ArrowDown: On last line, navigating to next block');
|
||||||
} else if (lastNode.nodeType === Node.TEXT_NODE) {
|
|
||||||
isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length;
|
|
||||||
} else if (block.type === 'list') {
|
|
||||||
// For lists, check if we're in the last item at the end
|
|
||||||
const lastLi = target.querySelector('li:last-child');
|
|
||||||
if (lastLi) {
|
|
||||||
const lastTextNode = this.getLastTextNode(lastLi);
|
|
||||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
|
||||||
range.endOffset === lastTextNode.textContent?.length;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other HTML content
|
|
||||||
const lastTextNode = this.getLastTextNode(target);
|
|
||||||
isAtEnd = lastTextNode && range.endContainer === lastTextNode &&
|
|
||||||
range.endOffset === lastTextNode.textContent?.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAtEnd) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const blockOps = this.component.blockOperations;
|
const blockOps = this.component.blockOperations;
|
||||||
const nextBlock = blockOps.getNextBlock(block.id);
|
const nextBlock = blockOps.getNextBlock(block.id);
|
||||||
|
|
||||||
if (nextBlock && nextBlock.type !== 'divider') {
|
if (nextBlock && nextBlock.type !== 'divider') {
|
||||||
|
console.log('ArrowDown: Focusing next block:', nextBlock.id);
|
||||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
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
|
* Handles ArrowLeft key - navigate to previous block if at beginning
|
||||||
*/
|
*/
|
||||||
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const selection = window.getSelection();
|
// Get the block component from the wysiwyg component's shadow DOM
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
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)
|
// Get selection info with proper shadow DOM support
|
||||||
if (range.collapsed && range.startOffset === 0) {
|
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||||
const target = e.target as HTMLElement;
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
const firstNode = target.firstChild;
|
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||||
|
shadowRoots.push(blockComponent.shadowRoot);
|
||||||
|
|
||||||
// Verify we're really at the start
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
const isAtStart = !firstNode ||
|
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||||
(range.startContainer === firstNode) ||
|
|
||||||
(range.startContainer === target);
|
|
||||||
|
|
||||||
if (isAtStart) {
|
// Check if cursor is at the beginning of the block
|
||||||
const blockOps = this.component.blockOperations;
|
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
console.log('ArrowLeft: Cursor position:', cursorPos, 'in block:', block.id);
|
||||||
|
|
||||||
if (prevBlock && prevBlock.type !== 'divider') {
|
if (cursorPos === 0) {
|
||||||
e.preventDefault();
|
const blockOps = this.component.blockOperations;
|
||||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
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
|
// 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
|
* Handles ArrowRight key - navigate to next block if at end
|
||||||
*/
|
*/
|
||||||
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||||
const selection = window.getSelection();
|
// Get the block component from the wysiwyg component's shadow DOM
|
||||||
if (!selection || selection.rangeCount === 0) return;
|
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 = e.target as HTMLElement;
|
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
|
// Get selection info with proper shadow DOM support
|
||||||
if (range.collapsed) {
|
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||||
const textLength = target.textContent?.length || 0;
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
let isAtEnd = false;
|
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||||
|
shadowRoots.push(blockComponent.shadowRoot);
|
||||||
|
|
||||||
if (textLength === 0) {
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
// Empty block
|
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||||
isAtEnd = true;
|
|
||||||
} else if (range.endContainer.nodeType === Node.TEXT_NODE) {
|
|
||||||
const textNode = range.endContainer as Text;
|
|
||||||
isAtEnd = range.endOffset === textNode.textContent?.length;
|
|
||||||
} else {
|
|
||||||
// Check if we're at the end of the last text node
|
|
||||||
const lastTextNode = this.getLastTextNode(target);
|
|
||||||
if (lastTextNode) {
|
|
||||||
isAtEnd = range.endContainer === lastTextNode &&
|
|
||||||
range.endOffset === lastTextNode.textContent?.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAtEnd) {
|
// Check if cursor is at the end of the block
|
||||||
const blockOps = this.component.blockOperations;
|
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||||
const nextBlock = blockOps.getNextBlock(block.id);
|
const textLength = target.textContent?.length || 0;
|
||||||
|
|
||||||
if (nextBlock && nextBlock.type !== 'divider') {
|
if (cursorPos === textLength) {
|
||||||
e.preventDefault();
|
const blockOps = this.component.blockOperations;
|
||||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
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
|
// Otherwise, let the browser handle normal right arrow navigation
|
||||||
@ -407,4 +500,78 @@ export class WysiwygKeyboardHandler {
|
|||||||
* Handles slash menu keyboard navigation
|
* Handles slash menu keyboard navigation
|
||||||
* Note: This is now handled by the component directly
|
* 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