Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
113c013ea9 | |||
0571d5bf4b | |||
5f86fdba72 | |||
474385a939 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@design.estate/dees-catalog",
|
"name": "@design.estate/dees-catalog",
|
||||||
"version": "1.9.1",
|
"version": "1.9.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||||
"main": "dist_ts_web/index.js",
|
"main": "dist_ts_web/index.js",
|
||||||
|
@ -1,5 +1,43 @@
|
|||||||
# WYSIWYG Editor Refactoring Progress Summary
|
# WYSIWYG Editor Refactoring Progress Summary
|
||||||
|
|
||||||
|
## Latest Updates
|
||||||
|
|
||||||
|
### Selection Highlighting Fix ✅
|
||||||
|
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
|
||||||
|
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
|
||||||
|
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
|
||||||
|
- **Result**: All block types now highlight consistently when selected
|
||||||
|
|
||||||
|
### Enter Key Block Creation Fix ✅
|
||||||
|
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
|
||||||
|
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
|
||||||
|
- **Solution**:
|
||||||
|
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
|
||||||
|
- Content is now set programmatically in the setup() method only when needed
|
||||||
|
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
|
||||||
|
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
|
||||||
|
|
||||||
|
### Backspace Key Deletion Fix ✅
|
||||||
|
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
|
||||||
|
- **Root Cause**:
|
||||||
|
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
|
||||||
|
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
|
||||||
|
- **Solution**:
|
||||||
|
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
|
||||||
|
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
|
||||||
|
3. Added debug logging to track cursor position and content state
|
||||||
|
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
|
||||||
|
|
||||||
|
### Arrow Left Navigation Fix ✅
|
||||||
|
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
|
||||||
|
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
|
||||||
|
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
|
||||||
|
1. Create a range pointing to the end of content
|
||||||
|
2. Apply the selection
|
||||||
|
3. Then focus the element (which preserves the existing selection)
|
||||||
|
4. Only use setCursorToEnd for empty blocks
|
||||||
|
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
|
||||||
|
|
||||||
## Completed Phases
|
## Completed Phases
|
||||||
|
|
||||||
### Phase 1: Infrastructure ✅
|
### Phase 1: Infrastructure ✅
|
||||||
|
9
test/test.wysiwyg-basic.browser.ts
Normal file
9
test/test.wysiwyg-basic.browser.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
|
||||||
|
tap.test('should create wysiwyg editor', async () => {
|
||||||
|
const editor = new DeesInputWysiwyg();
|
||||||
|
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Selection highlighting should work consistently for all block types', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import various block types
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
|
||||||
|
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
|
||||||
|
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
|
||||||
|
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
|
||||||
|
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Test paragraph highlighting
|
||||||
|
console.log('Testing paragraph highlighting...');
|
||||||
|
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||||
|
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus paragraph to select it
|
||||||
|
paraElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if paragraph has selected class
|
||||||
|
const paraHasSelected = paraElement.classList.contains('selected');
|
||||||
|
console.log('Paragraph has selected class:', paraHasSelected);
|
||||||
|
|
||||||
|
// Check computed styles
|
||||||
|
const paraStyle = window.getComputedStyle(paraElement);
|
||||||
|
console.log('Paragraph background:', paraStyle.background);
|
||||||
|
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
|
||||||
|
|
||||||
|
// Test heading highlighting
|
||||||
|
console.log('\nTesting heading highlighting...');
|
||||||
|
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
|
||||||
|
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus heading to select it
|
||||||
|
headingElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if heading has selected class
|
||||||
|
const headingHasSelected = headingElement.classList.contains('selected');
|
||||||
|
console.log('Heading has selected class:', headingHasSelected);
|
||||||
|
|
||||||
|
// Check computed styles
|
||||||
|
const headingStyle = window.getComputedStyle(headingElement);
|
||||||
|
console.log('Heading background:', headingStyle.background);
|
||||||
|
console.log('Heading box-shadow:', headingStyle.boxShadow);
|
||||||
|
|
||||||
|
// Test quote highlighting
|
||||||
|
console.log('\nTesting quote highlighting...');
|
||||||
|
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||||
|
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus quote to select it
|
||||||
|
quoteElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if quote has selected class
|
||||||
|
const quoteHasSelected = quoteElement.classList.contains('selected');
|
||||||
|
console.log('Quote has selected class:', quoteHasSelected);
|
||||||
|
|
||||||
|
// Test code highlighting
|
||||||
|
console.log('\nTesting code highlighting...');
|
||||||
|
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus code to select it
|
||||||
|
codeElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if code has selected class
|
||||||
|
const codeHasSelected = codeElement.classList.contains('selected');
|
||||||
|
console.log('Code has selected class:', codeHasSelected);
|
||||||
|
|
||||||
|
// Focus back on paragraph and check if others are deselected
|
||||||
|
console.log('\nFocusing back on paragraph...');
|
||||||
|
paraElement.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check that only paragraph is selected
|
||||||
|
expect(paraElement.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(headingElement.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(quoteElement.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(codeElement.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
console.log('Selection highlighting test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'block-1', type: 'paragraph', content: 'First block' },
|
||||||
|
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get both blocks
|
||||||
|
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||||
|
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||||
|
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Initially neither should be selected
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
// Click on first block
|
||||||
|
block1Element.click();
|
||||||
|
block1Element.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// First block should be selected
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||||
|
|
||||||
|
// Click on second block
|
||||||
|
block2Element.click();
|
||||||
|
block2Element.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Second block should be selected, first should not
|
||||||
|
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||||
|
expect(block2Element.classList.contains('selected')).toBeTrue();
|
||||||
|
|
||||||
|
console.log('Toggle test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
tap.test('Selection highlighting basic test', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
|
||||||
|
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Get paragraph element
|
||||||
|
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||||
|
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Get heading element
|
||||||
|
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
|
||||||
|
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
|
||||||
|
|
||||||
|
console.log('Found elements:', {
|
||||||
|
paraBlock: !!paraBlock,
|
||||||
|
headBlock: !!headBlock
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus paragraph
|
||||||
|
paraBlock.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check classes
|
||||||
|
console.log('Paragraph classes:', paraBlock.className);
|
||||||
|
console.log('Heading classes:', headBlock.className);
|
||||||
|
|
||||||
|
// Check isSelected property
|
||||||
|
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||||
|
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||||
|
|
||||||
|
// Focus heading
|
||||||
|
headBlock.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check classes again
|
||||||
|
console.log('\nAfter focusing heading:');
|
||||||
|
console.log('Paragraph classes:', paraBlock.className);
|
||||||
|
console.log('Heading classes:', headBlock.className);
|
||||||
|
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||||
|
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||||
|
|
||||||
|
// Check that heading is selected
|
||||||
|
expect(headBlock.classList.contains('selected')).toBeTrue();
|
||||||
|
expect(paraBlock.classList.contains('selected')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@ -25,7 +25,7 @@ export class CodeBlockHandler extends BaseBlockHandler {
|
|||||||
data-block-id="${block.id}"
|
data-block-id="${block.id}"
|
||||||
data-block-type="${block.type}"
|
data-block-type="${block.type}"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
>${block.content || ''}</div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,7 @@ export class HeadingBlockHandler extends BaseBlockHandler {
|
|||||||
data-placeholder="${placeholder}"
|
data-placeholder="${placeholder}"
|
||||||
data-block-id="${block.id}"
|
data-block-id="${block.id}"
|
||||||
data-block-type="${block.type}"
|
data-block-type="${block.type}"
|
||||||
>${block.content || ''}</div>
|
></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,6 +280,22 @@ export class HeadingBlockHandler extends BaseBlockHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the last text node in an element
|
||||||
|
*/
|
||||||
|
private getLastTextNode(element: Node): Text | null {
|
||||||
|
if (element.nodeType === Node.TEXT_NODE) {
|
||||||
|
return element as Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||||
|
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||||
|
if (lastText) return lastText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods for heading functionality (mostly the same as paragraph)
|
// Helper methods for heading functionality (mostly the same as paragraph)
|
||||||
|
|
||||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
@ -404,19 +420,40 @@ export class HeadingBlockHandler extends BaseBlockHandler {
|
|||||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
if (!headingBlock) return;
|
if (!headingBlock) return;
|
||||||
|
|
||||||
|
|
||||||
// Ensure element is focusable first
|
// Ensure element is focusable first
|
||||||
if (!headingBlock.hasAttribute('contenteditable')) {
|
if (!headingBlock.hasAttribute('contenteditable')) {
|
||||||
headingBlock.setAttribute('contenteditable', 'true');
|
headingBlock.setAttribute('contenteditable', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus the element
|
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||||
|
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
|
||||||
|
// Set up the selection first
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
|
||||||
|
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||||
|
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(lastNode);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now focus the element
|
||||||
headingBlock.focus();
|
headingBlock.focus();
|
||||||
|
|
||||||
// Set cursor position after focus is established
|
// Set cursor position after focus is established (for non-end positions)
|
||||||
const setCursor = () => {
|
const setCursor = () => {
|
||||||
if (position === 'start') {
|
if (position === 'start') {
|
||||||
this.setCursorToStart(element, context);
|
this.setCursorToStart(element, context);
|
||||||
} else if (position === 'end') {
|
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
|
||||||
|
// Only call setCursorToEnd for empty blocks
|
||||||
this.setCursorToEnd(element, context);
|
this.setCursorToEnd(element, context);
|
||||||
} else if (typeof position === 'number') {
|
} else if (typeof position === 'number') {
|
||||||
// Use the selection utility to set cursor position
|
// Use the selection utility to set cursor position
|
||||||
@ -432,6 +469,13 @@ export class HeadingBlockHandler extends BaseBlockHandler {
|
|||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||||
setCursor();
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Try again with a small delay - sometimes focus needs more time
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export class ParagraphBlockHandler extends BaseBlockHandler {
|
|||||||
data-placeholder="${placeholder}"
|
data-placeholder="${placeholder}"
|
||||||
data-block-id="${block.id}"
|
data-block-id="${block.id}"
|
||||||
data-block-type="${block.type}"
|
data-block-type="${block.type}"
|
||||||
>${block.content || ''}</div>
|
></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +246,22 @@ export class ParagraphBlockHandler extends BaseBlockHandler {
|
|||||||
return "Type '/' for commands...";
|
return "Type '/' for commands...";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the last text node in an element
|
||||||
|
*/
|
||||||
|
private getLastTextNode(element: Node): Text | null {
|
||||||
|
if (element.nodeType === Node.TEXT_NODE) {
|
||||||
|
return element as Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||||
|
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||||
|
if (lastText) return lastText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods for paragraph functionality
|
// Helper methods for paragraph functionality
|
||||||
|
|
||||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
@ -376,19 +392,40 @@ export class ParagraphBlockHandler extends BaseBlockHandler {
|
|||||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
if (!paragraphBlock) return;
|
if (!paragraphBlock) return;
|
||||||
|
|
||||||
|
|
||||||
// Ensure element is focusable first
|
// Ensure element is focusable first
|
||||||
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
||||||
paragraphBlock.setAttribute('contenteditable', 'true');
|
paragraphBlock.setAttribute('contenteditable', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus the element
|
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||||
|
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
|
||||||
|
// Set up the selection first
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
|
||||||
|
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||||
|
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(lastNode);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now focus the element
|
||||||
paragraphBlock.focus();
|
paragraphBlock.focus();
|
||||||
|
|
||||||
// Set cursor position after focus is established
|
// Set cursor position after focus is established (for non-end positions)
|
||||||
const setCursor = () => {
|
const setCursor = () => {
|
||||||
if (position === 'start') {
|
if (position === 'start') {
|
||||||
this.setCursorToStart(element, context);
|
this.setCursorToStart(element, context);
|
||||||
} else if (position === 'end') {
|
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
|
||||||
|
// Only call setCursorToEnd for empty blocks
|
||||||
this.setCursorToEnd(element, context);
|
this.setCursorToEnd(element, context);
|
||||||
} else if (typeof position === 'number') {
|
} else if (typeof position === 'number') {
|
||||||
// Use the selection utility to set cursor position
|
// Use the selection utility to set cursor position
|
||||||
@ -404,6 +441,13 @@ export class ParagraphBlockHandler extends BaseBlockHandler {
|
|||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||||
setCursor();
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Try again with a small delay - sometimes focus needs more time
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export class QuoteBlockHandler extends BaseBlockHandler {
|
|||||||
data-placeholder="${placeholder}"
|
data-placeholder="${placeholder}"
|
||||||
data-block-id="${block.id}"
|
data-block-id="${block.id}"
|
||||||
data-block-type="${block.type}"
|
data-block-type="${block.type}"
|
||||||
>${block.content || ''}</div>
|
></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -699,11 +699,17 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
|
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
|
||||||
// If selection state changed, we need to update for non-editable blocks
|
// If selection state changed, update the selected class without re-rendering
|
||||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
if (changedProperties.has('isSelected') && this.block) {
|
||||||
if (changedProperties.has('isSelected') && this.block && nonEditableTypes.includes(this.block.type)) {
|
// Find the block element based on block type
|
||||||
// For non-editable blocks, we need to update the selected class
|
let element: HTMLElement | null = null;
|
||||||
const element = this.shadowRoot?.querySelector('.block') as HTMLElement;
|
|
||||||
|
// Build the specific selector based on block type
|
||||||
|
const blockType = this.block.type;
|
||||||
|
const selector = `.block.${blockType}`;
|
||||||
|
|
||||||
|
element = this.shadowRoot?.querySelector(selector) as HTMLElement;
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
if (this.isSelected) {
|
if (this.isSelected) {
|
||||||
element.classList.add('selected');
|
element.classList.add('selected');
|
||||||
@ -1383,9 +1389,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
return handler.setCursorToStart(container, context);
|
return handler.setCursorToStart(container, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always find the element fresh, don't rely on cached blockElement
|
||||||
const editableElement = this.block?.type === 'code'
|
const editableElement = this.block?.type === 'code'
|
||||||
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
||||||
: this.blockElement;
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||||
if (editableElement) {
|
if (editableElement) {
|
||||||
WysiwygBlocks.setCursorToStart(editableElement);
|
WysiwygBlocks.setCursorToStart(editableElement);
|
||||||
}
|
}
|
||||||
@ -1400,9 +1407,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
return handler.setCursorToEnd(container, context);
|
return handler.setCursorToEnd(container, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always find the element fresh, don't rely on cached blockElement
|
||||||
const editableElement = this.block?.type === 'code'
|
const editableElement = this.block?.type === 'code'
|
||||||
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
||||||
: this.blockElement;
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||||
if (editableElement) {
|
if (editableElement) {
|
||||||
WysiwygBlocks.setCursorToEnd(editableElement);
|
WysiwygBlocks.setCursorToEnd(editableElement);
|
||||||
}
|
}
|
||||||
|
@ -306,6 +306,8 @@ export class WysiwygKeyboardHandler {
|
|||||||
|
|
||||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||||
|
|
||||||
|
const actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
|
||||||
|
|
||||||
// Check if cursor is at the beginning of the block
|
// Check if cursor is at the beginning of the block
|
||||||
if (cursorPos === 0) {
|
if (cursorPos === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -335,7 +337,8 @@ export class WysiwygKeyboardHandler {
|
|||||||
|
|
||||||
if (block.type === 'code' && prevBlock.type !== 'code') {
|
if (block.type === 'code' && prevBlock.type !== 'code') {
|
||||||
// Can't merge code into non-code block
|
// Can't merge code into non-code block
|
||||||
if (block.content === '') {
|
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||||
|
if (actualContent === '' || actualContent.trim() === '') {
|
||||||
blockOps.removeBlock(block.id);
|
blockOps.removeBlock(block.id);
|
||||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||||
}
|
}
|
||||||
@ -376,7 +379,11 @@ export class WysiwygKeyboardHandler {
|
|||||||
// Focus previous block at merge point
|
// Focus previous block at merge point
|
||||||
await blockOps.focusBlock(prevBlock.id, mergePoint);
|
await blockOps.focusBlock(prevBlock.id, mergePoint);
|
||||||
}
|
}
|
||||||
} else if (block.content === '' && this.component.blocks.length > 1) {
|
} else if (this.component.blocks.length > 1) {
|
||||||
|
// Check if block is actually empty by getting current content from DOM
|
||||||
|
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||||
|
|
||||||
|
if (currentContent === '' || currentContent.trim() === '') {
|
||||||
// Empty block - just remove it
|
// Empty block - just remove it
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||||
@ -389,6 +396,7 @@ export class WysiwygKeyboardHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Otherwise, let browser handle normal backspace
|
// Otherwise, let browser handle normal backspace
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,7 +663,8 @@ export class WysiwygKeyboardHandler {
|
|||||||
if (prevBlock) {
|
if (prevBlock) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
|
||||||
|
await blockOps.focusBlock(prevBlock.id, position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise, let the browser handle normal left arrow navigation
|
// Otherwise, let the browser handle normal left arrow navigation
|
||||||
|
@ -122,9 +122,13 @@ export class WysiwygSelection {
|
|||||||
range.selectNodeContents(element);
|
range.selectNodeContents(element);
|
||||||
|
|
||||||
// Handle case where selection is in a text node that's a child of the element
|
// Handle case where selection is in a text node that's a child of the element
|
||||||
if (element.contains(selectionInfo.startContainer)) {
|
// Use our Shadow DOM-aware contains method
|
||||||
|
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
|
||||||
|
|
||||||
|
if (isContained) {
|
||||||
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
return range.toString().length;
|
const position = range.toString().length;
|
||||||
|
return position;
|
||||||
} else {
|
} else {
|
||||||
// Selection might be in shadow DOM or different context
|
// Selection might be in shadow DOM or different context
|
||||||
// Try to find the equivalent position in the element
|
// Try to find the equivalent position in the element
|
||||||
|
Reference in New Issue
Block a user