refactor
This commit is contained in:
@ -7,7 +7,7 @@
|
|||||||
"typings": "dist_ts_web/index.d.ts",
|
"typings": "dist_ts_web/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "tstest test/ --web",
|
"test": "tstest test/ --web --verbose --timeout 30",
|
||||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||||
"watch": "tswatch element",
|
"watch": "tswatch element",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
|
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
100
readme.refactoring-summary.md
Normal file
100
readme.refactoring-summary.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
# WYSIWYG Editor Refactoring Progress Summary
|
||||||
|
|
||||||
|
## Completed Phases
|
||||||
|
|
||||||
|
### Phase 1: Infrastructure ✅
|
||||||
|
- Created modular block handler architecture
|
||||||
|
- Implemented `IBlockHandler` interface and `BaseBlockHandler` class
|
||||||
|
- Created `BlockRegistry` for dynamic block type registration
|
||||||
|
- Set up proper file structure under `blocks/` directory
|
||||||
|
|
||||||
|
### Phase 2: Proof of Concept ✅
|
||||||
|
- Successfully migrated divider block as the simplest example
|
||||||
|
- Validated the architecture works correctly
|
||||||
|
- Established patterns for block migration
|
||||||
|
|
||||||
|
### Phase 3: Text Blocks ✅
|
||||||
|
- **Paragraph Block**: Full editing support with text splitting, selection handling, and cursor tracking
|
||||||
|
- **Heading Blocks**: All three heading levels (h1, h2, h3) with unified handler
|
||||||
|
- **Quote Block**: Italic styling with border, full editing capabilities
|
||||||
|
- **Code Block**: Monospace font, tab handling, plain text paste support
|
||||||
|
- **List Block**: Bullet/numbered lists with proper list item management
|
||||||
|
|
||||||
|
## Key Achievements
|
||||||
|
|
||||||
|
### 1. Preserved Critical Knowledge
|
||||||
|
- **Static Rendering**: Blocks use `innerHTML` in `firstUpdated` to prevent focus loss during typing
|
||||||
|
- **Shadow DOM Selection**: Implemented `containsAcrossShadowDOM` utility for proper selection detection
|
||||||
|
- **Cursor Position Tracking**: All editable blocks track cursor position across multiple events
|
||||||
|
- **Content Splitting**: HTML-aware splitting using Range API preserves formatting
|
||||||
|
- **Focus Management**: Microtask-based focus restoration ensures reliable cursor placement
|
||||||
|
|
||||||
|
### 2. Enhanced Architecture
|
||||||
|
- Each block type is now self-contained in its own file
|
||||||
|
- Block handlers are dynamically registered and loaded
|
||||||
|
- Common functionality is shared through base classes
|
||||||
|
- Styles are co-located with their block handlers
|
||||||
|
|
||||||
|
### 3. Maintained Functionality
|
||||||
|
- All keyboard navigation works (arrows, backspace, delete, enter)
|
||||||
|
- Text selection across Shadow DOM boundaries functions correctly
|
||||||
|
- Block merging and splitting behave as before
|
||||||
|
- IME (Input Method Editor) support is preserved
|
||||||
|
- Formatting shortcuts (Cmd/Ctrl+B/I/U/K) continue to work
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
ts_web/elements/wysiwyg/
|
||||||
|
├── dees-wysiwyg-block.ts (simplified main component)
|
||||||
|
├── wysiwyg.selection.ts (Shadow DOM selection utilities)
|
||||||
|
├── wysiwyg.blockregistration.ts (handler registration)
|
||||||
|
└── blocks/
|
||||||
|
├── index.ts (exports and registry)
|
||||||
|
├── block.base.ts (base handler interface)
|
||||||
|
├── decorative/
|
||||||
|
│ └── divider.block.ts
|
||||||
|
└── text/
|
||||||
|
├── paragraph.block.ts
|
||||||
|
├── heading.block.ts
|
||||||
|
├── quote.block.ts
|
||||||
|
├── code.block.ts
|
||||||
|
└── list.block.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Phase 4: Media Blocks (In Progress)
|
||||||
|
- Image block with upload/drag-drop support
|
||||||
|
- YouTube block with video embedding
|
||||||
|
- Attachment block for file uploads
|
||||||
|
|
||||||
|
### Phase 5: Content Blocks
|
||||||
|
- Markdown block with preview toggle
|
||||||
|
- HTML block with raw HTML editing
|
||||||
|
|
||||||
|
### Phase 6: Cleanup
|
||||||
|
- Remove old code from main component
|
||||||
|
- Optimize bundle size
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
1. **Modularity**: Each block type is now completely self-contained
|
||||||
|
2. **Extensibility**: New blocks can be added by creating a handler and registering it
|
||||||
|
3. **Maintainability**: Files are smaller and focused on single responsibilities
|
||||||
|
4. **Type Safety**: Strong TypeScript interfaces ensure consistent implementation
|
||||||
|
5. **Performance**: No degradation in performance; potential for lazy loading in future
|
||||||
|
|
||||||
|
## Migration Pattern
|
||||||
|
|
||||||
|
For future block migrations, follow this pattern:
|
||||||
|
|
||||||
|
1. Create block handler extending `BaseBlockHandler`
|
||||||
|
2. Implement required methods: `render()`, `setup()`, `getStyles()`
|
||||||
|
3. Add helper methods for cursor/content management
|
||||||
|
4. Handle Shadow DOM selection properly using utilities
|
||||||
|
5. Register handler in `wysiwyg.blockregistration.ts`
|
||||||
|
6. Test all interactions (typing, selection, navigation)
|
||||||
|
|
||||||
|
The refactoring has been successful in making the codebase more maintainable while preserving all the hard-won functionality and edge case handling from the original implementation.
|
@ -1,6 +1,6 @@
|
|||||||
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
import * as deesCatalog from '../ts_web';
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
|
||||||
tap.test('should create a working button', async () => {
|
tap.test('should create a working button', async () => {
|
||||||
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
||||||
@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
|
|||||||
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
|
expect(button).toBeInstanceOf(deesCatalog.DeesButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.start();
|
export default tap.start();
|
||||||
|
175
test/test.shadow-dom-containment.browser.ts
Normal file
175
test/test.shadow-dom-containment.browser.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { expect, tap, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
import { WysiwygSelection } from '../ts_web/elements/wysiwyg/wysiwyg.selection.js';
|
||||||
|
|
||||||
|
tap.test('Shadow DOM containment should work correctly', async () => {
|
||||||
|
console.log('=== Testing Shadow DOM Containment ===');
|
||||||
|
|
||||||
|
// Create a WYSIWYG block component
|
||||||
|
const block = await webhelpers.fixture<DeesWysiwygBlock>(
|
||||||
|
'<dees-wysiwyg-block></dees-wysiwyg-block>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the block data
|
||||||
|
block.block = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Hello world test content'
|
||||||
|
};
|
||||||
|
|
||||||
|
block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await block.updateComplete;
|
||||||
|
|
||||||
|
// Get the paragraph element inside Shadow DOM
|
||||||
|
const container = block.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(paragraphBlock).toBeTruthy();
|
||||||
|
console.log('Found paragraph block:', paragraphBlock);
|
||||||
|
console.log('Paragraph text content:', paragraphBlock.textContent);
|
||||||
|
|
||||||
|
// Focus the paragraph
|
||||||
|
paragraphBlock.focus();
|
||||||
|
|
||||||
|
// Manually set cursor position
|
||||||
|
const textNode = paragraphBlock.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
|
||||||
|
// Set cursor at position 11 (after "Hello world")
|
||||||
|
range.setStart(textNode, 11);
|
||||||
|
range.setEnd(textNode, 11);
|
||||||
|
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
console.log('Set cursor at position 11');
|
||||||
|
|
||||||
|
// Test the containment check
|
||||||
|
console.log('\n--- Testing containment ---');
|
||||||
|
const currentSelection = window.getSelection();
|
||||||
|
if (currentSelection && currentSelection.rangeCount > 0) {
|
||||||
|
const selRange = currentSelection.getRangeAt(0);
|
||||||
|
console.log('Selection range:', {
|
||||||
|
startContainer: selRange.startContainer,
|
||||||
|
startOffset: selRange.startOffset,
|
||||||
|
containerText: selRange.startContainer.textContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test regular contains (should fail across Shadow DOM)
|
||||||
|
const regularContains = paragraphBlock.contains(selRange.startContainer);
|
||||||
|
console.log('Regular contains:', regularContains);
|
||||||
|
|
||||||
|
// Test Shadow DOM-aware contains
|
||||||
|
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selRange.startContainer);
|
||||||
|
console.log('Shadow DOM contains:', shadowDOMContains);
|
||||||
|
|
||||||
|
// Since we're setting selection within the same shadow DOM, both should be true
|
||||||
|
expect(regularContains).toBeTrue();
|
||||||
|
expect(shadowDOMContains).toBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getSplitContent
|
||||||
|
console.log('\n--- Testing getSplitContent ---');
|
||||||
|
const splitResult = block.getSplitContent();
|
||||||
|
console.log('Split result:', splitResult);
|
||||||
|
|
||||||
|
expect(splitResult).toBeTruthy();
|
||||||
|
if (splitResult) {
|
||||||
|
console.log('Before:', JSON.stringify(splitResult.before));
|
||||||
|
console.log('After:', JSON.stringify(splitResult.after));
|
||||||
|
|
||||||
|
// Expected split at position 11
|
||||||
|
expect(splitResult.before).toEqual('Hello world');
|
||||||
|
expect(splitResult.after).toEqual(' test content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Shadow DOM containment across different shadow roots', async () => {
|
||||||
|
console.log('=== Testing Cross Shadow Root Containment ===');
|
||||||
|
|
||||||
|
// Create parent component with WYSIWYG editor
|
||||||
|
const parentDiv = document.createElement('div');
|
||||||
|
parentDiv.innerHTML = `
|
||||||
|
<dees-input-wysiwyg>
|
||||||
|
<dees-wysiwyg-block></dees-wysiwyg-block>
|
||||||
|
</dees-input-wysiwyg>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(parentDiv);
|
||||||
|
|
||||||
|
// Wait for components to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const wysiwygInput = parentDiv.querySelector('dees-input-wysiwyg') as any;
|
||||||
|
const blockElement = wysiwygInput?.shadowRoot?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
|
||||||
|
if (blockElement) {
|
||||||
|
// Set block data
|
||||||
|
blockElement.block = {
|
||||||
|
id: 'test-2',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Cross shadow DOM test'
|
||||||
|
};
|
||||||
|
|
||||||
|
blockElement.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await blockElement.updateComplete;
|
||||||
|
|
||||||
|
// Get the paragraph inside the nested shadow DOM
|
||||||
|
const container = blockElement.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraphBlock = container?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
if (paragraphBlock) {
|
||||||
|
console.log('Found nested paragraph block');
|
||||||
|
|
||||||
|
// Focus and set selection
|
||||||
|
paragraphBlock.focus();
|
||||||
|
const textNode = paragraphBlock.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(textNode, 5);
|
||||||
|
range.setEnd(textNode, 5);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
// Test containment from parent's perspective
|
||||||
|
const selRange = selection?.getRangeAt(0);
|
||||||
|
if (selRange) {
|
||||||
|
// This should fail because it crosses shadow DOM boundary
|
||||||
|
const regularContains = wysiwygInput.contains(selRange.startContainer);
|
||||||
|
console.log('Parent regular contains:', regularContains);
|
||||||
|
expect(regularContains).toBeFalse();
|
||||||
|
|
||||||
|
// This should work with our Shadow DOM-aware method
|
||||||
|
const shadowDOMContains = WysiwygSelection.containsAcrossShadowDOM(wysiwygInput, selRange.startContainer);
|
||||||
|
console.log('Parent shadow DOM contains:', shadowDOMContains);
|
||||||
|
expect(shadowDOMContains).toBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.body.removeChild(parentDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
69
test/test.wysiwyg-blocks-debug.browser.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
|
||||||
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('Debug: should create empty wysiwyg block component', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Creating DeesWysiwygBlock...');
|
||||||
|
const block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
console.log('Block created:', block);
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
expect(block).toBeInstanceOf(DeesWysiwygBlock);
|
||||||
|
console.log('Initial block property:', block.block);
|
||||||
|
console.log('Initial handlers property:', block.handlers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating block:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Debug: should set properties step by step', async () => {
|
||||||
|
try {
|
||||||
|
console.log('Step 1: Creating component...');
|
||||||
|
const block: DeesWysiwygBlock = document.createElement('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(block).toBeDefined();
|
||||||
|
|
||||||
|
console.log('Step 2: Setting handlers...');
|
||||||
|
block.handlers = {
|
||||||
|
onInput: () => console.log('onInput'),
|
||||||
|
onKeyDown: () => console.log('onKeyDown'),
|
||||||
|
onFocus: () => console.log('onFocus'),
|
||||||
|
onBlur: () => console.log('onBlur'),
|
||||||
|
onCompositionStart: () => console.log('onCompositionStart'),
|
||||||
|
onCompositionEnd: () => console.log('onCompositionEnd')
|
||||||
|
};
|
||||||
|
console.log('Handlers set:', block.handlers);
|
||||||
|
|
||||||
|
console.log('Step 3: Setting block data...');
|
||||||
|
block.block = {
|
||||||
|
id: 'test-block',
|
||||||
|
type: 'divider',
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
console.log('Block set:', block.block);
|
||||||
|
|
||||||
|
console.log('Step 4: Appending to body...');
|
||||||
|
document.body.appendChild(block);
|
||||||
|
|
||||||
|
console.log('Step 5: Waiting for update...');
|
||||||
|
await block.updateComplete;
|
||||||
|
console.log('Update complete');
|
||||||
|
|
||||||
|
console.log('Step 6: Checking shadowRoot...');
|
||||||
|
expect(block.shadowRoot).toBeDefined();
|
||||||
|
console.log('ShadowRoot exists');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in step-by-step test:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
205
test/test.wysiwyg-blocks.browser.ts
Normal file
205
test/test.wysiwyg-blocks.browser.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@push.rocks/tapbundle';
|
||||||
|
|
||||||
|
import * as deesCatalog from '../ts_web/index.js';
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('BlockRegistry should have registered handlers', async () => {
|
||||||
|
// Test divider handler
|
||||||
|
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(dividerHandler).toBeDefined();
|
||||||
|
expect(dividerHandler?.type).toEqual('divider');
|
||||||
|
|
||||||
|
// Test paragraph handler
|
||||||
|
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(paragraphHandler).toBeDefined();
|
||||||
|
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Test heading handlers
|
||||||
|
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(heading1Handler).toBeDefined();
|
||||||
|
expect(heading1Handler?.type).toEqual('heading-1');
|
||||||
|
|
||||||
|
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||||
|
expect(heading2Handler).toBeDefined();
|
||||||
|
expect(heading2Handler?.type).toEqual('heading-2');
|
||||||
|
|
||||||
|
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||||
|
expect(heading3Handler).toBeDefined();
|
||||||
|
expect(heading3Handler?.type).toEqual('heading-3');
|
||||||
|
|
||||||
|
// Test that getAllTypes returns all registered types
|
||||||
|
const allTypes = BlockRegistry.getAllTypes();
|
||||||
|
expect(allTypes).toContain('divider');
|
||||||
|
expect(allTypes).toContain('paragraph');
|
||||||
|
expect(allTypes).toContain('heading-1');
|
||||||
|
expect(allTypes).toContain('heading-2');
|
||||||
|
expect(allTypes).toContain('heading-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render divider block using handler', async () => {
|
||||||
|
const dividerBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
dividerBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a divider block
|
||||||
|
dividerBlock.block = {
|
||||||
|
id: 'test-divider',
|
||||||
|
type: 'divider',
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
|
||||||
|
await dividerBlock.updateComplete;
|
||||||
|
|
||||||
|
// Check that the divider is rendered
|
||||||
|
const dividerElement = dividerBlock.shadowRoot?.querySelector('.block.divider');
|
||||||
|
expect(dividerElement).toBeDefined();
|
||||||
|
expect(dividerElement?.getAttribute('tabindex')).toEqual('0');
|
||||||
|
|
||||||
|
// Check for the divider icon
|
||||||
|
const icon = dividerBlock.shadowRoot?.querySelector('.divider-icon');
|
||||||
|
expect(icon).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render paragraph block using handler', async () => {
|
||||||
|
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
paragraphBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a paragraph block
|
||||||
|
paragraphBlock.block = {
|
||||||
|
id: 'test-paragraph',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Test paragraph content'
|
||||||
|
};
|
||||||
|
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
|
||||||
|
// Check that the paragraph is rendered
|
||||||
|
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||||
|
expect(paragraphElement).toBeDefined();
|
||||||
|
expect(paragraphElement?.getAttribute('contenteditable')).toEqual('true');
|
||||||
|
expect(paragraphElement?.textContent).toEqual('Test paragraph content');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should render heading blocks using handler', async () => {
|
||||||
|
// Test heading-1
|
||||||
|
const heading1Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
heading1Block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
heading1Block.block = {
|
||||||
|
id: 'test-h1',
|
||||||
|
type: 'heading-1',
|
||||||
|
content: 'Heading 1 Test'
|
||||||
|
};
|
||||||
|
|
||||||
|
await heading1Block.updateComplete;
|
||||||
|
|
||||||
|
const h1Element = heading1Block.shadowRoot?.querySelector('.block.heading-1');
|
||||||
|
expect(h1Element).toBeDefined();
|
||||||
|
expect(h1Element?.textContent).toEqual('Heading 1 Test');
|
||||||
|
|
||||||
|
// Test heading-2
|
||||||
|
const heading2Block: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
heading2Block.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
heading2Block.block = {
|
||||||
|
id: 'test-h2',
|
||||||
|
type: 'heading-2',
|
||||||
|
content: 'Heading 2 Test'
|
||||||
|
};
|
||||||
|
|
||||||
|
await heading2Block.updateComplete;
|
||||||
|
|
||||||
|
const h2Element = heading2Block.shadowRoot?.querySelector('.block.heading-2');
|
||||||
|
expect(h2Element).toBeDefined();
|
||||||
|
expect(h2Element?.textContent).toEqual('Heading 2 Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('paragraph block handler methods should work', async () => {
|
||||||
|
const paragraphBlock: DeesWysiwygBlock = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-wysiwyg-block></dees-wysiwyg-block>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set required handlers
|
||||||
|
paragraphBlock.handlers = {
|
||||||
|
onInput: () => {},
|
||||||
|
onKeyDown: () => {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onCompositionStart: () => {},
|
||||||
|
onCompositionEnd: () => {},
|
||||||
|
onMouseUp: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
paragraphBlock.block = {
|
||||||
|
id: 'test-methods',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Initial content'
|
||||||
|
};
|
||||||
|
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
|
||||||
|
// Test getContent
|
||||||
|
const content = paragraphBlock.getContent();
|
||||||
|
expect(content).toEqual('Initial content');
|
||||||
|
|
||||||
|
// Test setContent
|
||||||
|
paragraphBlock.setContent('Updated content');
|
||||||
|
await paragraphBlock.updateComplete;
|
||||||
|
expect(paragraphBlock.getContent()).toEqual('Updated content');
|
||||||
|
|
||||||
|
// Test that the DOM is updated
|
||||||
|
const paragraphElement = paragraphBlock.shadowRoot?.querySelector('.block.paragraph');
|
||||||
|
expect(paragraphElement?.textContent).toEqual('Updated content');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
341
test/test.wysiwyg-keyboard.browser.ts
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
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('Keyboard: Arrow navigation between blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import multiple blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'block-1', type: 'paragraph', content: 'First paragraph' },
|
||||||
|
{ id: 'block-2', type: 'paragraph', content: 'Second paragraph' },
|
||||||
|
{ id: 'block-3', type: 'paragraph', content: 'Third paragraph' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus first block at end
|
||||||
|
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||||
|
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const firstBlockContainer = firstBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const firstParagraph = firstBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at end of first block
|
||||||
|
firstParagraph.focus();
|
||||||
|
const textNode = firstParagraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||||
|
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press ArrowRight to move to second block
|
||||||
|
const arrowRightEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowRight',
|
||||||
|
code: 'ArrowRight',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
firstParagraph.dispatchEvent(arrowRightEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if second block is focused
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Check if the second paragraph has focus
|
||||||
|
const activeElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||||
|
expect(activeElement).toEqual(secondParagraph);
|
||||||
|
|
||||||
|
console.log('Arrow navigation test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Backspace merges blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import two blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'merge-1', type: 'paragraph', content: 'First' },
|
||||||
|
{ id: 'merge-2', type: 'paragraph', content: 'Second' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus second block at beginning
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="merge-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at beginning
|
||||||
|
secondParagraph.focus();
|
||||||
|
const textNode = secondParagraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 0);
|
||||||
|
range.setEnd(textNode, 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Backspace to merge with previous block
|
||||||
|
const backspaceEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Backspace',
|
||||||
|
code: 'Backspace',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
secondParagraph.dispatchEvent(backspaceEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if blocks were merged
|
||||||
|
expect(editor.blocks.length).toEqual(1);
|
||||||
|
expect(editor.blocks[0].content).toContain('First');
|
||||||
|
expect(editor.blocks[0].content).toContain('Second');
|
||||||
|
|
||||||
|
console.log('Backspace merge test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Delete key on non-editable blocks', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import blocks including a divider
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'para-1', type: 'paragraph', content: 'Before divider' },
|
||||||
|
{ id: 'div-1', type: 'divider', content: '' },
|
||||||
|
{ id: 'para-2', type: 'paragraph', content: 'After divider' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus the divider block
|
||||||
|
const dividerBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="div-1"]');
|
||||||
|
const dividerBlockComponent = dividerBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const dividerBlockContainer = dividerBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const dividerElement = dividerBlockContainer?.querySelector('.block.divider') as HTMLElement;
|
||||||
|
|
||||||
|
// Non-editable blocks need to be focused differently
|
||||||
|
dividerElement?.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Delete to remove the divider
|
||||||
|
const deleteEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Delete',
|
||||||
|
code: 'Delete',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
dividerElement.dispatchEvent(deleteEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if divider was removed
|
||||||
|
expect(editor.blocks.length).toEqual(2);
|
||||||
|
expect(editor.blocks.find(b => b.type === 'divider')).toBeUndefined();
|
||||||
|
|
||||||
|
console.log('Delete key on non-editable block test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Tab key in code block', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a code block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'code-1', type: 'code', content: 'function test() {', metadata: { language: 'javascript' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus code block
|
||||||
|
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeBlockContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeBlockContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor at end
|
||||||
|
codeElement.focus();
|
||||||
|
const textNode = codeElement.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, textNode.textContent?.length || 0);
|
||||||
|
range.setEnd(textNode, textNode.textContent?.length || 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Tab to insert spaces
|
||||||
|
const tabEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Tab',
|
||||||
|
code: 'Tab',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
codeElement.dispatchEvent(tabEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if spaces were inserted
|
||||||
|
const updatedContent = codeElement.textContent || '';
|
||||||
|
expect(updatedContent).toContain(' '); // Tab should insert 2 spaces
|
||||||
|
|
||||||
|
console.log('Tab in code block test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: ArrowUp/Down navigation', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import multiple blocks
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'nav-1', type: 'paragraph', content: 'First line' },
|
||||||
|
{ id: 'nav-2', type: 'paragraph', content: 'Second line' },
|
||||||
|
{ id: 'nav-3', type: 'paragraph', content: 'Third line' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus second block
|
||||||
|
const secondBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-2"]');
|
||||||
|
const secondBlockComponent = secondBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const secondBlockContainer = secondBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const secondParagraph = secondBlockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
secondParagraph.focus();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press ArrowUp to move to first block
|
||||||
|
const arrowUpEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowUp',
|
||||||
|
code: 'ArrowUp',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
secondParagraph.dispatchEvent(arrowUpEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if first block is focused
|
||||||
|
const firstBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-1"]');
|
||||||
|
const firstBlockComponent = firstBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const firstParagraph = firstBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(firstBlockComponent.shadowRoot?.activeElement).toEqual(firstParagraph);
|
||||||
|
|
||||||
|
// Now press ArrowDown twice to get to third block
|
||||||
|
const arrowDownEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowDown',
|
||||||
|
code: 'ArrowDown',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
firstParagraph.dispatchEvent(arrowDownEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Second block should be focused, dispatch again
|
||||||
|
const secondActiveElement = secondBlockComponent.shadowRoot?.activeElement;
|
||||||
|
if (secondActiveElement) {
|
||||||
|
secondActiveElement.dispatchEvent(arrowDownEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if third block is focused
|
||||||
|
const thirdBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="nav-3"]');
|
||||||
|
const thirdBlockComponent = thirdBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const thirdParagraph = thirdBlockComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
expect(thirdBlockComponent.shadowRoot?.activeElement).toEqual(thirdParagraph);
|
||||||
|
|
||||||
|
console.log('ArrowUp/Down navigation test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Keyboard: Formatting shortcuts', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a paragraph
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'format-1', type: 'paragraph', content: 'Test formatting' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Focus and select text
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="format-1"]');
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const blockContainer = blockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const paragraph = blockContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||||
|
|
||||||
|
paragraph.focus();
|
||||||
|
|
||||||
|
// Select "formatting"
|
||||||
|
const textNode = paragraph.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 5); // After "Test "
|
||||||
|
range.setEnd(textNode, 15); // After "formatting"
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Cmd/Ctrl+B for bold
|
||||||
|
const boldEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'b',
|
||||||
|
code: 'KeyB',
|
||||||
|
metaKey: true, // Use metaKey for Mac, ctrlKey for Windows/Linux
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
paragraph.dispatchEvent(boldEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if bold was applied
|
||||||
|
const content = paragraph.innerHTML;
|
||||||
|
expect(content).toContain('<strong>') || expect(content).toContain('<b>');
|
||||||
|
|
||||||
|
console.log('Formatting shortcuts test complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
150
test/test.wysiwyg-phase3.browser.ts
Normal file
150
test/test.wysiwyg-phase3.browser.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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('Phase 3: Quote block should render and work correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a quote block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'quote-1', type: 'quote', content: 'This is a famous quote' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if quote block was rendered
|
||||||
|
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||||
|
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(quoteBlockComponent).toBeTruthy();
|
||||||
|
|
||||||
|
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
expect(quoteElement).toBeTruthy();
|
||||||
|
expect(quoteElement?.textContent).toEqual('This is a famous quote');
|
||||||
|
|
||||||
|
// Check if styles are applied (border-left for quote)
|
||||||
|
const computedStyle = window.getComputedStyle(quoteElement);
|
||||||
|
expect(computedStyle.borderLeftStyle).toEqual('solid');
|
||||||
|
expect(computedStyle.fontStyle).toEqual('italic');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: Code block should render and handle tab correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a code block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'code-1', type: 'code', content: 'const x = 42;', metadata: { language: 'javascript' } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if code block was rendered
|
||||||
|
const codeBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||||
|
const codeBlockComponent = codeBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const codeContainer = codeBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||||
|
|
||||||
|
expect(codeElement).toBeTruthy();
|
||||||
|
expect(codeElement?.textContent).toEqual('const x = 42;');
|
||||||
|
|
||||||
|
// Check if language label is shown
|
||||||
|
const languageLabel = codeContainer?.querySelector('.code-language');
|
||||||
|
expect(languageLabel?.textContent).toEqual('javascript');
|
||||||
|
|
||||||
|
// Check if monospace font is applied
|
||||||
|
const computedStyle = window.getComputedStyle(codeElement);
|
||||||
|
expect(computedStyle.fontFamily).toContain('monospace');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: List block should render correctly', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a list block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'list-1', type: 'list', content: 'First item\nSecond item\nThird item' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check if list block was rendered
|
||||||
|
const listBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="list-1"]');
|
||||||
|
const listBlockComponent = listBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const listContainer = listBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const listElement = listContainer?.querySelector('.block.list') as HTMLElement;
|
||||||
|
|
||||||
|
expect(listElement).toBeTruthy();
|
||||||
|
|
||||||
|
// Check if list items were created
|
||||||
|
const listItems = listElement?.querySelectorAll('li');
|
||||||
|
expect(listItems?.length).toEqual(3);
|
||||||
|
expect(listItems?.[0].textContent).toEqual('First item');
|
||||||
|
expect(listItems?.[1].textContent).toEqual('Second item');
|
||||||
|
expect(listItems?.[2].textContent).toEqual('Third item');
|
||||||
|
|
||||||
|
// Check if it's an unordered list by default
|
||||||
|
const ulElement = listElement?.querySelector('ul');
|
||||||
|
expect(ulElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Phase 3: Quote block split should work', async () => {
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a quote block
|
||||||
|
editor.importBlocks([
|
||||||
|
{ id: 'quote-split', type: 'quote', content: 'To be or not to be' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the quote block
|
||||||
|
const quoteBlockWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-split"]');
|
||||||
|
const quoteBlockComponent = quoteBlockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const quoteContainer = quoteBlockComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||||
|
|
||||||
|
// Focus and set cursor after "To be"
|
||||||
|
quoteElement.focus();
|
||||||
|
const textNode = quoteElement.firstChild;
|
||||||
|
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 5); // After "To be"
|
||||||
|
range.setEnd(textNode, 5);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Press Enter to split
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
quoteElement.dispatchEvent(enterEvent);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if split happened correctly
|
||||||
|
expect(editor.blocks.length).toEqual(2);
|
||||||
|
expect(editor.blocks[0].content).toEqual('To be');
|
||||||
|
expect(editor.blocks[1].content).toEqual(' or not to be');
|
||||||
|
expect(editor.blocks[1].type).toEqual('paragraph'); // New block should be paragraph
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
105
test/test.wysiwyg-registry.both.ts
Normal file
105
test/test.wysiwyg-registry.both.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { BlockRegistry } from '../ts_web/elements/wysiwyg/blocks/block.registry.js';
|
||||||
|
import { DividerBlockHandler } from '../ts_web/elements/wysiwyg/blocks/content/divider.block.js';
|
||||||
|
import { ParagraphBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/paragraph.block.js';
|
||||||
|
import { HeadingBlockHandler } from '../ts_web/elements/wysiwyg/blocks/text/heading.block.js';
|
||||||
|
|
||||||
|
// Import block registration to ensure handlers are registered
|
||||||
|
import '../ts_web/elements/wysiwyg/wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
|
tap.test('BlockRegistry should register and retrieve handlers', async () => {
|
||||||
|
// Test divider handler
|
||||||
|
const dividerHandler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(dividerHandler).toBeDefined();
|
||||||
|
expect(dividerHandler).toBeInstanceOf(DividerBlockHandler);
|
||||||
|
expect(dividerHandler?.type).toEqual('divider');
|
||||||
|
|
||||||
|
// Test paragraph handler
|
||||||
|
const paragraphHandler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(paragraphHandler).toBeDefined();
|
||||||
|
expect(paragraphHandler).toBeInstanceOf(ParagraphBlockHandler);
|
||||||
|
expect(paragraphHandler?.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Test heading handlers
|
||||||
|
const heading1Handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(heading1Handler).toBeDefined();
|
||||||
|
expect(heading1Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading1Handler?.type).toEqual('heading-1');
|
||||||
|
|
||||||
|
const heading2Handler = BlockRegistry.getHandler('heading-2');
|
||||||
|
expect(heading2Handler).toBeDefined();
|
||||||
|
expect(heading2Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading2Handler?.type).toEqual('heading-2');
|
||||||
|
|
||||||
|
const heading3Handler = BlockRegistry.getHandler('heading-3');
|
||||||
|
expect(heading3Handler).toBeDefined();
|
||||||
|
expect(heading3Handler).toBeInstanceOf(HeadingBlockHandler);
|
||||||
|
expect(heading3Handler?.type).toEqual('heading-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Block handlers should render content correctly', async () => {
|
||||||
|
const testBlock = {
|
||||||
|
id: 'test-1',
|
||||||
|
type: 'paragraph' as const,
|
||||||
|
content: 'Test paragraph content'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('paragraph');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(testBlock, false);
|
||||||
|
expect(rendered).toContain('contenteditable="true"');
|
||||||
|
expect(rendered).toContain('data-block-type="paragraph"');
|
||||||
|
expect(rendered).toContain('Test paragraph content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Divider handler should render correctly', async () => {
|
||||||
|
const dividerBlock = {
|
||||||
|
id: 'test-divider',
|
||||||
|
type: 'divider' as const,
|
||||||
|
content: ' '
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('divider');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(dividerBlock, false);
|
||||||
|
expect(rendered).toContain('class="block divider"');
|
||||||
|
expect(rendered).toContain('tabindex="0"');
|
||||||
|
expect(rendered).toContain('divider-icon');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Heading handlers should render with correct levels', async () => {
|
||||||
|
const headingBlock = {
|
||||||
|
id: 'test-h1',
|
||||||
|
type: 'heading-1' as const,
|
||||||
|
content: 'Test Heading'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = BlockRegistry.getHandler('heading-1');
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
const rendered = handler.render(headingBlock, false);
|
||||||
|
expect(rendered).toContain('class="block heading-1"');
|
||||||
|
expect(rendered).toContain('contenteditable="true"');
|
||||||
|
expect(rendered).toContain('Test Heading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('getAllTypes should return all registered types', async () => {
|
||||||
|
const allTypes = BlockRegistry.getAllTypes();
|
||||||
|
expect(allTypes).toContain('divider');
|
||||||
|
expect(allTypes).toContain('paragraph');
|
||||||
|
expect(allTypes).toContain('heading-1');
|
||||||
|
expect(allTypes).toContain('heading-2');
|
||||||
|
expect(allTypes).toContain('heading-3');
|
||||||
|
expect(allTypes.length).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
98
test/test.wysiwyg-split.browser.ts
Normal file
98
test/test.wysiwyg-split.browser.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { tap, expect, 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('should split paragraph content on Enter key', async () => {
|
||||||
|
// Create the wysiwyg editor
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a test paragraph
|
||||||
|
editor.importBlocks([{
|
||||||
|
id: 'test-para-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Hello World'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
|
||||||
|
// Wait for blocks to render
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Get the block wrapper and component
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-para-1"]');
|
||||||
|
expect(blockWrapper).toBeDefined();
|
||||||
|
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
expect(blockComponent).toBeDefined();
|
||||||
|
expect(blockComponent.block.type).toEqual('paragraph');
|
||||||
|
|
||||||
|
// Wait for block to render
|
||||||
|
await blockComponent.updateComplete;
|
||||||
|
|
||||||
|
// Test getSplitContent
|
||||||
|
console.log('Testing getSplitContent...');
|
||||||
|
const splitResult = blockComponent.getSplitContent();
|
||||||
|
console.log('Split result:', splitResult);
|
||||||
|
|
||||||
|
// Since we haven't set cursor position, it might return null or split at start
|
||||||
|
// This is just to test if the method is callable
|
||||||
|
expect(typeof blockComponent.getSplitContent).toEqual('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should handle Enter key press in paragraph', async () => {
|
||||||
|
// Create the wysiwyg editor
|
||||||
|
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||||
|
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import a test paragraph
|
||||||
|
editor.importBlocks([{
|
||||||
|
id: 'test-enter-1',
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'First part|Second part' // | marks where we'll simulate cursor
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await editor.updateComplete;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Check initial state
|
||||||
|
expect(editor.blocks.length).toEqual(1);
|
||||||
|
expect(editor.blocks[0].content).toEqual('First part|Second part');
|
||||||
|
|
||||||
|
// Get the block element
|
||||||
|
const blockWrapper = editor.shadowRoot?.querySelector('[data-block-id="test-enter-1"]');
|
||||||
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||||
|
const blockElement = blockComponent.shadowRoot?.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
|
||||||
|
expect(blockElement).toBeDefined();
|
||||||
|
|
||||||
|
// Set content without the | marker
|
||||||
|
blockElement.textContent = 'First partSecond part';
|
||||||
|
|
||||||
|
// Focus the block
|
||||||
|
blockElement.focus();
|
||||||
|
|
||||||
|
// Create and dispatch Enter key event
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch the event
|
||||||
|
blockElement.dispatchEvent(enterEvent);
|
||||||
|
|
||||||
|
// Wait for processing
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Check if block was split (this might not work perfectly in test environment)
|
||||||
|
console.log('Blocks after Enter:', editor.blocks.length);
|
||||||
|
console.log('Block contents:', editor.blocks.map(b => b.content));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
78
ts_web/elements/wysiwyg/MIGRATION-STATUS.md
Normal file
78
ts_web/elements/wysiwyg/MIGRATION-STATUS.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# WYSIWYG Block Migration Status
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
|
||||||
|
|
||||||
|
## Migration Progress
|
||||||
|
|
||||||
|
### ✅ Phase 1: Architecture Foundation
|
||||||
|
- Created block handler base classes and interfaces
|
||||||
|
- Created block registry system
|
||||||
|
- Created common block styles and utilities
|
||||||
|
|
||||||
|
### ✅ Phase 2: Divider Block
|
||||||
|
- Simple non-editable block as proof of concept
|
||||||
|
- See `phase2-summary.md` for details
|
||||||
|
|
||||||
|
### ✅ Phase 3: Paragraph Block
|
||||||
|
- First text block with full editing capabilities
|
||||||
|
- Established patterns for text selection, cursor tracking, and content splitting
|
||||||
|
- See commit history for implementation details
|
||||||
|
|
||||||
|
### ✅ Phase 4: Heading Blocks
|
||||||
|
- All three heading levels (h1, h2, h3) using unified handler
|
||||||
|
- See `phase4-summary.md` for details
|
||||||
|
|
||||||
|
### 🔄 Phase 5: Other Text Blocks (In Progress)
|
||||||
|
- [ ] Quote block
|
||||||
|
- [ ] Code block
|
||||||
|
- [ ] List block
|
||||||
|
|
||||||
|
### 📋 Phase 6: Media Blocks (Planned)
|
||||||
|
- [ ] Image block
|
||||||
|
- [ ] YouTube block
|
||||||
|
- [ ] Attachment block
|
||||||
|
|
||||||
|
### 📋 Phase 7: Content Blocks (Planned)
|
||||||
|
- [ ] Markdown block
|
||||||
|
- [ ] HTML block
|
||||||
|
|
||||||
|
## Block Handler Status
|
||||||
|
|
||||||
|
| Block Type | Handler Created | Registered | Tested | Notes |
|
||||||
|
|------------|----------------|------------|---------|-------|
|
||||||
|
| divider | ✅ | ✅ | ✅ | Complete |
|
||||||
|
| paragraph | ✅ | ✅ | ✅ | Complete |
|
||||||
|
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||||
|
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||||
|
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||||
|
| quote | ❌ | ❌ | ❌ | |
|
||||||
|
| code | ❌ | ❌ | ❌ | |
|
||||||
|
| list | ❌ | ❌ | ❌ | |
|
||||||
|
| image | ❌ | ❌ | ❌ | |
|
||||||
|
| youtube | ❌ | ❌ | ❌ | |
|
||||||
|
| markdown | ❌ | ❌ | ❌ | |
|
||||||
|
| html | ❌ | ❌ | ❌ | |
|
||||||
|
| attachment | ❌ | ❌ | ❌ | |
|
||||||
|
|
||||||
|
## Files Modified During Migration
|
||||||
|
|
||||||
|
### Core Architecture Files
|
||||||
|
- `blocks/block.base.ts` - Base handler interface and class
|
||||||
|
- `blocks/block.registry.ts` - Registry for handlers
|
||||||
|
- `blocks/block.styles.ts` - Common styles
|
||||||
|
- `blocks/index.ts` - Main exports
|
||||||
|
- `wysiwyg.blockregistration.ts` - Registration of all handlers
|
||||||
|
|
||||||
|
### Handler Files Created
|
||||||
|
- `blocks/content/divider.block.ts`
|
||||||
|
- `blocks/text/paragraph.block.ts`
|
||||||
|
- `blocks/text/heading.block.ts`
|
||||||
|
|
||||||
|
### Main Component Updates
|
||||||
|
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Continue with quote block migration
|
||||||
|
2. Follow established patterns from paragraph/heading handlers
|
||||||
|
3. Test thoroughly after each migration
|
294
ts_web/elements/wysiwyg/blocks/MIGRATION-KNOWLEDGE.md
Normal file
294
ts_web/elements/wysiwyg/blocks/MIGRATION-KNOWLEDGE.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# Critical WYSIWYG Knowledge - DO NOT LOSE
|
||||||
|
|
||||||
|
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
|
||||||
|
|
||||||
|
## 1. Static Rendering to Prevent Focus Loss
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
When using Lit's reactive rendering, every state change would cause a re-render, which would:
|
||||||
|
- Lose cursor position
|
||||||
|
- Lose focus state
|
||||||
|
- Interrupt typing
|
||||||
|
- Break IME (Input Method Editor) support
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
We render blocks **statically** and manage updates imperatively:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In dees-wysiwyg-block.ts
|
||||||
|
render(): TemplateResult {
|
||||||
|
if (!this.block) return html``;
|
||||||
|
// Render empty container - content set in firstUpdated
|
||||||
|
return html`<div class="wysiwyg-block-container"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated(): void {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
|
||||||
|
if (container && this.block) {
|
||||||
|
container.innerHTML = this.renderBlockContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Pattern
|
||||||
|
- NEVER use reactive properties that would trigger re-renders during typing
|
||||||
|
- Use `shouldUpdate()` to prevent unnecessary renders
|
||||||
|
- Manage content updates imperatively through event handlers
|
||||||
|
|
||||||
|
## 2. Shadow DOM Selection Handling
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
|
||||||
|
- Text selection
|
||||||
|
- Cursor position tracking
|
||||||
|
- Formatting detection
|
||||||
|
- Content splitting for Enter key
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Use the `getComposedRanges` API with all relevant shadow roots:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// From paragraph.block.ts
|
||||||
|
const wysiwygBlock = element.closest('dees-wysiwyg-block');
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
|
||||||
|
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Pattern
|
||||||
|
- ALWAYS collect all shadow roots in the hierarchy
|
||||||
|
- Use `WysiwygSelection` utility methods that handle shadow DOM
|
||||||
|
- Never use raw `window.getSelection()` without shadow root context
|
||||||
|
|
||||||
|
## 3. Cursor Position Tracking
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Cursor position would be lost during various operations, making it impossible to:
|
||||||
|
- Split content at the right position for Enter key
|
||||||
|
- Restore cursor after operations
|
||||||
|
- Track position for formatting
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Track cursor position through multiple events and maintain `lastKnownCursorPosition`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Track on every relevant event
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
|
||||||
|
// In event handlers:
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when selection not available:
|
||||||
|
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
|
||||||
|
// Use last known position
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical Events to Track
|
||||||
|
- `input` - After text changes
|
||||||
|
- `keydown` - Before key press
|
||||||
|
- `keyup` - After key press
|
||||||
|
- `mouseup` - After mouse selection
|
||||||
|
- `click` - With setTimeout(0) for browser to set cursor
|
||||||
|
|
||||||
|
## 4. Content Splitting for Enter Key
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Splitting content at cursor position while preserving HTML formatting was complex due to:
|
||||||
|
- Need to preserve formatting tags
|
||||||
|
- Shadow DOM complications
|
||||||
|
- Cursor position accuracy
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Use Range API to split content while preserving HTML:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getSplitContent(): { before: string; after: string } | null {
|
||||||
|
// Create ranges for before and after cursor
|
||||||
|
const beforeRange = document.createRange();
|
||||||
|
const afterRange = document.createRange();
|
||||||
|
|
||||||
|
beforeRange.setStart(element, 0);
|
||||||
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
afterRange.setEnd(element, element.childNodes.length);
|
||||||
|
|
||||||
|
// Extract HTML content
|
||||||
|
const beforeFragment = beforeRange.cloneContents();
|
||||||
|
const afterFragment = afterRange.cloneContents();
|
||||||
|
|
||||||
|
// Convert to HTML strings
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.appendChild(beforeFragment);
|
||||||
|
const beforeHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
tempDiv.innerHTML = '';
|
||||||
|
tempDiv.appendChild(afterFragment);
|
||||||
|
const afterHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
return { before: beforeHtml, after: afterHtml };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Focus Management
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Focus would be lost or not properly set due to:
|
||||||
|
- Timing issues with DOM updates
|
||||||
|
- Shadow DOM complications
|
||||||
|
- Browser inconsistencies
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Use defensive focus management with fallbacks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
focus(element: HTMLElement): void {
|
||||||
|
const block = element.querySelector('.block');
|
||||||
|
if (!block) return;
|
||||||
|
|
||||||
|
// Ensure focusable
|
||||||
|
if (!block.hasAttribute('contenteditable')) {
|
||||||
|
block.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
block.focus();
|
||||||
|
|
||||||
|
// Fallback with microtask if focus failed
|
||||||
|
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
block.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Selection Event Handling for Formatting
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Dispatch custom events with selection information:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Listen for selection changes
|
||||||
|
document.addEventListener('selectionchange', () => {
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
|
||||||
|
if (selectedText !== this.lastSelectedText) {
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Dispatch custom event
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: selectedText,
|
||||||
|
blockId: block.id,
|
||||||
|
range: range,
|
||||||
|
rect: rect,
|
||||||
|
hasSelection: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom event bubbles through Shadow DOM
|
||||||
|
const event = new CustomEvent('block-text-selected', {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. IME (Input Method Editor) Support
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Composition events for non-Latin input methods would break without proper handling.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Track composition state and handle events:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In dees-input-wysiwyg.ts
|
||||||
|
public isComposing: boolean = false;
|
||||||
|
|
||||||
|
// In block handlers
|
||||||
|
element.addEventListener('compositionstart', () => {
|
||||||
|
handlers.onCompositionStart(); // Sets isComposing = true
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('compositionend', () => {
|
||||||
|
handlers.onCompositionEnd(); // Sets isComposing = false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't process certain operations during composition
|
||||||
|
if (this.isComposing) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Programmatic Rendering
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Render blocks programmatically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
public renderBlocksProgrammatically() {
|
||||||
|
if (!this.editorContentRef) return;
|
||||||
|
|
||||||
|
// Clear existing blocks
|
||||||
|
this.editorContentRef.innerHTML = '';
|
||||||
|
|
||||||
|
// Create and append block elements
|
||||||
|
this.blocks.forEach(block => {
|
||||||
|
const blockWrapper = this.createBlockElement(block);
|
||||||
|
this.editorContentRef.appendChild(blockWrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Block Handler Architecture Requirements
|
||||||
|
|
||||||
|
When creating new block handlers, they MUST:
|
||||||
|
|
||||||
|
1. **Implement all cursor/selection methods** even if not applicable
|
||||||
|
2. **Use Shadow DOM-aware selection utilities**
|
||||||
|
3. **Track cursor position through events**
|
||||||
|
4. **Handle focus with fallbacks**
|
||||||
|
5. **Preserve HTML content when getting/setting**
|
||||||
|
6. **Dispatch selection events for formatting**
|
||||||
|
7. **Support IME composition events**
|
||||||
|
8. **Clean up event listeners on disconnect**
|
||||||
|
|
||||||
|
## 10. Testing Considerations
|
||||||
|
|
||||||
|
### webhelpers.fixture() Issue
|
||||||
|
The test helper `webhelpers.fixture()` triggers property changes during initialization that can cause null reference errors. Always:
|
||||||
|
|
||||||
|
1. Check for null/undefined before accessing nested properties
|
||||||
|
2. Set required properties in specific order when testing
|
||||||
|
3. Consider manual element creation for complex test scenarios
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
These patterns represent hours of debugging and problem-solving. When refactoring:
|
||||||
|
|
||||||
|
1. **NEVER** remove static rendering approach
|
||||||
|
2. **ALWAYS** use Shadow DOM-aware selection utilities
|
||||||
|
3. **MAINTAIN** cursor position tracking through all events
|
||||||
|
4. **PRESERVE** the complex content splitting logic
|
||||||
|
5. **KEEP** all focus management fallbacks
|
||||||
|
6. **ENSURE** selection events bubble through Shadow DOM
|
||||||
|
7. **SUPPORT** IME composition events
|
||||||
|
8. **TEST** thoroughly with actual typing, not just unit tests
|
||||||
|
|
||||||
|
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.
|
49
ts_web/elements/wysiwyg/blocks/block.base.ts
Normal file
49
ts_web/elements/wysiwyg/blocks/block.base.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { IBlock } from '../wysiwyg.types.js';
|
||||||
|
|
||||||
|
export interface IBlockContext {
|
||||||
|
shadowRoot: ShadowRoot;
|
||||||
|
component: any; // Reference to the wysiwyg-block component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBlockHandler {
|
||||||
|
type: string;
|
||||||
|
render(block: IBlock, isSelected: boolean): string;
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void;
|
||||||
|
getStyles(): string;
|
||||||
|
getPlaceholder?(): string;
|
||||||
|
|
||||||
|
// Optional methods for editable blocks - now with context
|
||||||
|
getContent?(element: HTMLElement, context?: IBlockContext): string;
|
||||||
|
setContent?(element: HTMLElement, content: string, context?: IBlockContext): void;
|
||||||
|
getCursorPosition?(element: HTMLElement, context?: IBlockContext): number | null;
|
||||||
|
setCursorToStart?(element: HTMLElement, context?: IBlockContext): void;
|
||||||
|
setCursorToEnd?(element: HTMLElement, context?: IBlockContext): void;
|
||||||
|
focus?(element: HTMLElement, context?: IBlockContext): void;
|
||||||
|
focusWithCursor?(element: HTMLElement, position: 'start' | 'end' | number, context?: IBlockContext): void;
|
||||||
|
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBlockEventHandlers {
|
||||||
|
onInput: (e: InputEvent) => void;
|
||||||
|
onKeyDown: (e: KeyboardEvent) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
onCompositionStart: () => void;
|
||||||
|
onCompositionEnd: () => void;
|
||||||
|
onMouseUp?: (e: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseBlockHandler implements IBlockHandler {
|
||||||
|
abstract type: string;
|
||||||
|
abstract render(block: IBlock, isSelected: boolean): string;
|
||||||
|
|
||||||
|
// Default implementation for common setup
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
// Common setup logic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common styles can be defined here
|
||||||
|
getStyles(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
17
ts_web/elements/wysiwyg/blocks/block.registry.ts
Normal file
17
ts_web/elements/wysiwyg/blocks/block.registry.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { IBlockHandler } from './block.base.js';
|
||||||
|
|
||||||
|
export class BlockRegistry {
|
||||||
|
private static handlers = new Map<string, IBlockHandler>();
|
||||||
|
|
||||||
|
static register(type: string, handler: IBlockHandler): void {
|
||||||
|
this.handlers.set(type, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getHandler(type: string): IBlockHandler | undefined {
|
||||||
|
return this.handlers.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getAllTypes(): string[] {
|
||||||
|
return Array.from(this.handlers.keys());
|
||||||
|
}
|
||||||
|
}
|
64
ts_web/elements/wysiwyg/blocks/block.styles.ts
Normal file
64
ts_web/elements/wysiwyg/blocks/block.styles.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Common styles shared across all block types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const commonBlockStyles = `
|
||||||
|
/* Common block spacing and layout */
|
||||||
|
/* TODO: Extract common spacing from existing blocks */
|
||||||
|
|
||||||
|
/* Common focus states */
|
||||||
|
/* TODO: Extract common focus styles */
|
||||||
|
|
||||||
|
/* Common selected states */
|
||||||
|
/* TODO: Extract common selection styles */
|
||||||
|
|
||||||
|
/* Common hover states */
|
||||||
|
/* TODO: Extract common hover styles */
|
||||||
|
|
||||||
|
/* Common transition effects */
|
||||||
|
/* TODO: Extract common transitions */
|
||||||
|
|
||||||
|
/* Common placeholder styles */
|
||||||
|
/* TODO: Extract common placeholder styles */
|
||||||
|
|
||||||
|
/* Common error states */
|
||||||
|
/* TODO: Extract common error styles */
|
||||||
|
|
||||||
|
/* Common loading states */
|
||||||
|
/* TODO: Extract common loading styles */
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to generate consistent block classes
|
||||||
|
*/
|
||||||
|
export const getBlockClasses = (
|
||||||
|
type: string,
|
||||||
|
isSelected: boolean,
|
||||||
|
additionalClasses: string[] = []
|
||||||
|
): string => {
|
||||||
|
const classes = ['block', type];
|
||||||
|
if (isSelected) {
|
||||||
|
classes.push('selected');
|
||||||
|
}
|
||||||
|
classes.push(...additionalClasses);
|
||||||
|
return classes.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to generate consistent data attributes
|
||||||
|
*/
|
||||||
|
export const getBlockDataAttributes = (
|
||||||
|
blockId: string,
|
||||||
|
blockType: string,
|
||||||
|
additionalAttributes: Record<string, string> = {}
|
||||||
|
): string => {
|
||||||
|
const attributes = {
|
||||||
|
'data-block-id': blockId,
|
||||||
|
'data-block-type': blockType,
|
||||||
|
...additionalAttributes
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(attributes)
|
||||||
|
.map(([key, value]) => `${key}="${value}"`)
|
||||||
|
.join(' ');
|
||||||
|
};
|
80
ts_web/elements/wysiwyg/blocks/content/divider.block.ts
Normal file
80
ts_web/elements/wysiwyg/blocks/content/divider.block.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export class DividerBlockHandler extends BaseBlockHandler {
|
||||||
|
type = 'divider';
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
return `
|
||||||
|
<div class="block divider${selectedClass}" data-block-id="${block.id}" data-block-type="${block.type}" tabindex="0">
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const dividerBlock = element.querySelector('.block.divider') as HTMLDivElement;
|
||||||
|
if (!dividerBlock) return;
|
||||||
|
|
||||||
|
// Handle click to select
|
||||||
|
dividerBlock.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Focus will trigger the selection
|
||||||
|
dividerBlock.focus();
|
||||||
|
// Ensure focus handler is called immediately
|
||||||
|
handlers.onFocus?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle focus/blur
|
||||||
|
dividerBlock.addEventListener('focus', () => {
|
||||||
|
handlers.onFocus?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
dividerBlock.addEventListener('blur', () => {
|
||||||
|
handlers.onBlur?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle keyboard events
|
||||||
|
dividerBlock.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
// Let the keyboard handler in the parent component handle the deletion
|
||||||
|
handlers.onKeyDown?.(e);
|
||||||
|
} else {
|
||||||
|
// Handle navigation keys
|
||||||
|
handlers.onKeyDown?.(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
return `
|
||||||
|
.block.divider {
|
||||||
|
padding: 8px 0;
|
||||||
|
margin: 16px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.divider:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.divider.selected {
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
|
||||||
|
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.divider hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
46
ts_web/elements/wysiwyg/blocks/index.ts
Normal file
46
ts_web/elements/wysiwyg/blocks/index.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Main exports for the blocks module
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Core interfaces and base classes
|
||||||
|
export {
|
||||||
|
type IBlockHandler,
|
||||||
|
type IBlockEventHandlers,
|
||||||
|
BaseBlockHandler
|
||||||
|
} from './block.base.js';
|
||||||
|
|
||||||
|
// Block registry for registration and retrieval
|
||||||
|
export { BlockRegistry } from './block.registry.js';
|
||||||
|
|
||||||
|
// Common styles and helpers
|
||||||
|
export {
|
||||||
|
commonBlockStyles,
|
||||||
|
getBlockClasses,
|
||||||
|
getBlockDataAttributes
|
||||||
|
} from './block.styles.js';
|
||||||
|
|
||||||
|
// Text block handlers
|
||||||
|
export { ParagraphBlockHandler } from './text/paragraph.block.js';
|
||||||
|
export { HeadingBlockHandler } from './text/heading.block.js';
|
||||||
|
// TODO: Export when implemented
|
||||||
|
// export { QuoteBlockHandler } from './text/quote.block.js';
|
||||||
|
// export { CodeBlockHandler } from './text/code.block.js';
|
||||||
|
// export { ListBlockHandler } from './text/list.block.js';
|
||||||
|
|
||||||
|
// Media block handlers
|
||||||
|
// TODO: Export when implemented
|
||||||
|
// export { ImageBlockHandler } from './media/image.block.js';
|
||||||
|
// export { YoutubeBlockHandler } from './media/youtube.block.js';
|
||||||
|
// export { AttachmentBlockHandler } from './media/attachment.block.js';
|
||||||
|
|
||||||
|
// Content block handlers
|
||||||
|
export { DividerBlockHandler } from './content/divider.block.js';
|
||||||
|
// TODO: Export when implemented
|
||||||
|
// export { MarkdownBlockHandler } from './content/markdown.block.js';
|
||||||
|
// export { HtmlBlockHandler } from './content/html.block.js';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
// TODO: Export when implemented
|
||||||
|
// export * from './utils/file.utils.js';
|
||||||
|
// export * from './utils/media.utils.js';
|
||||||
|
// export * from './utils/markdown.utils.js';
|
411
ts_web/elements/wysiwyg/blocks/text/code.block.ts
Normal file
411
ts_web/elements/wysiwyg/blocks/text/code.block.ts
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||||
|
|
||||||
|
export class CodeBlockHandler extends BaseBlockHandler {
|
||||||
|
type = 'code';
|
||||||
|
|
||||||
|
// Track cursor position
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const language = block.metadata?.language || 'plain text';
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
|
||||||
|
console.log('CodeBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, language });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="code-block-container">
|
||||||
|
<div class="code-language">${language}</div>
|
||||||
|
<div
|
||||||
|
class="block code${selectedClass}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-block-id="${block.id}"
|
||||||
|
data-block-type="${block.type}"
|
||||||
|
spellcheck="false"
|
||||||
|
>${block.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) {
|
||||||
|
console.error('CodeBlockHandler.setup: No code block element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('CodeBlockHandler.setup: Setting up code block', { blockId: block.id });
|
||||||
|
|
||||||
|
// Set initial content if needed - use textContent for code blocks
|
||||||
|
if (block.content && !codeBlock.textContent) {
|
||||||
|
codeBlock.textContent = block.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler
|
||||||
|
codeBlock.addEventListener('input', (e) => {
|
||||||
|
console.log('CodeBlockHandler: Input event', { blockId: block.id });
|
||||||
|
handlers.onInput(e as InputEvent);
|
||||||
|
|
||||||
|
// Track cursor position after input
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keydown handler
|
||||||
|
codeBlock.addEventListener('keydown', (e) => {
|
||||||
|
// Track cursor position before keydown
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for Tab key in code blocks
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
// Insert two spaces for tab
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const textNode = document.createTextNode(' ');
|
||||||
|
range.insertNode(textNode);
|
||||||
|
range.setStartAfter(textNode);
|
||||||
|
range.setEndAfter(textNode);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
// Trigger input event
|
||||||
|
handlers.onInput(new InputEvent('input'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus handler
|
||||||
|
codeBlock.addEventListener('focus', () => {
|
||||||
|
console.log('CodeBlockHandler: Focus event', { blockId: block.id });
|
||||||
|
handlers.onFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
codeBlock.addEventListener('blur', () => {
|
||||||
|
console.log('CodeBlockHandler: Blur event', { blockId: block.id });
|
||||||
|
handlers.onBlur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition handlers for IME support
|
||||||
|
codeBlock.addEventListener('compositionstart', () => {
|
||||||
|
console.log('CodeBlockHandler: Composition start', { blockId: block.id });
|
||||||
|
handlers.onCompositionStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
codeBlock.addEventListener('compositionend', () => {
|
||||||
|
console.log('CodeBlockHandler: Composition end', { blockId: block.id });
|
||||||
|
handlers.onCompositionEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
codeBlock.addEventListener('mouseup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
handlers.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler with delayed cursor tracking
|
||||||
|
codeBlock.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyup handler for cursor tracking
|
||||||
|
codeBlock.addEventListener('keyup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paste handler - handle as plain text
|
||||||
|
codeBlock.addEventListener('paste', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData?.getData('text/plain');
|
||||||
|
if (text) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
const textNode = document.createTextNode(text);
|
||||||
|
range.insertNode(textNode);
|
||||||
|
range.setStartAfter(textNode);
|
||||||
|
range.setEndAfter(textNode);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
// Trigger input event
|
||||||
|
handlers.onInput(new InputEvent('input'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
return `
|
||||||
|
/* Code block specific styles */
|
||||||
|
.code-block-container {
|
||||||
|
position: relative;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.code {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
|
||||||
|
padding: 16px 20px;
|
||||||
|
padding-top: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-language {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: ${cssManager.bdTheme('#e1e4e8', '#333333')};
|
||||||
|
color: ${cssManager.bdTheme('#586069', '#8b949e')};
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 0 6px 0 6px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
text-transform: lowercase;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholder(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for code functionality
|
||||||
|
|
||||||
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
|
// Get the actual code element
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) {
|
||||||
|
console.log('CodeBlockHandler.getCursorPosition: No code element found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a range from start of element to cursor position
|
||||||
|
const preCaretRange = document.createRange();
|
||||||
|
preCaretRange.selectNodeContents(codeBlock);
|
||||||
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// Get the text content length up to cursor
|
||||||
|
const position = preCaretRange.toString().length;
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent(element: HTMLElement, context?: any): string {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) return '';
|
||||||
|
|
||||||
|
// For code blocks, get textContent to avoid HTML formatting
|
||||||
|
const content = codeBlock.textContent || '';
|
||||||
|
console.log('CodeBlockHandler.getContent:', content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) return;
|
||||||
|
|
||||||
|
// Store if we have focus
|
||||||
|
const hadFocus = document.activeElement === codeBlock ||
|
||||||
|
element.shadowRoot?.activeElement === codeBlock;
|
||||||
|
|
||||||
|
// Use textContent for code blocks
|
||||||
|
codeBlock.textContent = content;
|
||||||
|
|
||||||
|
// Restore focus if we had it
|
||||||
|
if (hadFocus) {
|
||||||
|
codeBlock.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (codeBlock) {
|
||||||
|
WysiwygBlocks.setCursorToStart(codeBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (codeBlock) {
|
||||||
|
WysiwygBlocks.setCursorToEnd(codeBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element: HTMLElement, context?: any): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) return;
|
||||||
|
|
||||||
|
// Ensure the element is focusable
|
||||||
|
if (!codeBlock.hasAttribute('contenteditable')) {
|
||||||
|
codeBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
codeBlock.focus();
|
||||||
|
|
||||||
|
// If focus failed, try again after a microtask
|
||||||
|
if (document.activeElement !== codeBlock && element.shadowRoot?.activeElement !== codeBlock) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
codeBlock.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) return;
|
||||||
|
|
||||||
|
// Ensure element is focusable first
|
||||||
|
if (!codeBlock.hasAttribute('contenteditable')) {
|
||||||
|
codeBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the element
|
||||||
|
codeBlock.focus();
|
||||||
|
|
||||||
|
// Set cursor position after focus is established
|
||||||
|
const setCursor = () => {
|
||||||
|
if (position === 'start') {
|
||||||
|
this.setCursorToStart(element, context);
|
||||||
|
} else if (position === 'end') {
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
} else if (typeof position === 'number') {
|
||||||
|
// Use the selection utility to set cursor position
|
||||||
|
WysiwygSelection.setCursorPosition(codeBlock, position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cursor is set after focus
|
||||||
|
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
|
||||||
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Wait for focus to be established
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||||
|
const codeBlock = element.querySelector('.block.code') as HTMLDivElement;
|
||||||
|
if (!codeBlock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = codeBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the selection is within this block
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) {
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = codeBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position
|
||||||
|
const cursorPos = this.getCursorPosition(element, context);
|
||||||
|
|
||||||
|
if (cursorPos === null || cursorPos === 0) {
|
||||||
|
// If cursor is at start or can't determine position, move all content
|
||||||
|
return {
|
||||||
|
before: '',
|
||||||
|
after: codeBlock.textContent || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For code blocks, split based on text content only
|
||||||
|
const fullText = codeBlock.textContent || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, cursorPos),
|
||||||
|
after: fullText.substring(cursorPos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
566
ts_web/elements/wysiwyg/blocks/text/heading.block.ts
Normal file
566
ts_web/elements/wysiwyg/blocks/text/heading.block.ts
Normal file
@ -0,0 +1,566 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||||
|
|
||||||
|
export class HeadingBlockHandler extends BaseBlockHandler {
|
||||||
|
type: string;
|
||||||
|
private level: 1 | 2 | 3;
|
||||||
|
|
||||||
|
// Track cursor position
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
private lastSelectedText: string = '';
|
||||||
|
private selectionHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
|
||||||
|
super();
|
||||||
|
this.type = type;
|
||||||
|
this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
const placeholder = this.getPlaceholder();
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, level: this.level });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div
|
||||||
|
class="block heading-${this.level}${selectedClass}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-placeholder="${placeholder}"
|
||||||
|
data-block-id="${block.id}"
|
||||||
|
data-block-type="${block.type}"
|
||||||
|
>${block.content || ''}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) {
|
||||||
|
console.error('HeadingBlockHandler.setup: No heading block element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.setup: Setting up heading block', { blockId: block.id, level: this.level });
|
||||||
|
|
||||||
|
// Set initial content if needed
|
||||||
|
if (block.content && !headingBlock.innerHTML) {
|
||||||
|
headingBlock.innerHTML = block.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler with cursor tracking
|
||||||
|
headingBlock.addEventListener('input', (e) => {
|
||||||
|
console.log('HeadingBlockHandler: Input event', { blockId: block.id });
|
||||||
|
handlers.onInput(e as InputEvent);
|
||||||
|
|
||||||
|
// Track cursor position after input
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('HeadingBlockHandler: Updated cursor position after input', { pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keydown handler with cursor tracking
|
||||||
|
headingBlock.addEventListener('keydown', (e) => {
|
||||||
|
// Track cursor position before keydown
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('HeadingBlockHandler: Cursor position before keydown', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus handler
|
||||||
|
headingBlock.addEventListener('focus', () => {
|
||||||
|
console.log('HeadingBlockHandler: Focus event', { blockId: block.id });
|
||||||
|
handlers.onFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
headingBlock.addEventListener('blur', () => {
|
||||||
|
console.log('HeadingBlockHandler: Blur event', { blockId: block.id });
|
||||||
|
handlers.onBlur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition handlers for IME support
|
||||||
|
headingBlock.addEventListener('compositionstart', () => {
|
||||||
|
console.log('HeadingBlockHandler: Composition start', { blockId: block.id });
|
||||||
|
handlers.onCompositionStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
headingBlock.addEventListener('compositionend', () => {
|
||||||
|
console.log('HeadingBlockHandler: Composition end', { blockId: block.id });
|
||||||
|
handlers.onCompositionEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
headingBlock.addEventListener('mouseup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('HeadingBlockHandler: Cursor position after mouseup', { pos });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection will be handled by selectionchange event
|
||||||
|
handlers.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler with delayed cursor tracking
|
||||||
|
headingBlock.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
// Small delay to let browser set cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('HeadingBlockHandler: Cursor position after click', { pos });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyup handler for additional cursor tracking
|
||||||
|
headingBlock.addEventListener('keyup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('HeadingBlockHandler: Cursor position after keyup', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up selection change handler
|
||||||
|
this.setupSelectionHandler(element, headingBlock, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
|
||||||
|
// Add selection change handler
|
||||||
|
const checkSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
if (selectedText.length === 0) {
|
||||||
|
// Clear selection if no text
|
||||||
|
if (this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent wysiwyg component's shadow root - in setup, we need to traverse
|
||||||
|
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||||
|
|
||||||
|
// Use getComposedRanges with shadow roots as per MDN docs
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
// Get selection info using our Shadow DOM-aware utility
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
if (!selectionInfo) return;
|
||||||
|
|
||||||
|
// Check if selection is within this block
|
||||||
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
|
||||||
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
|
||||||
|
|
||||||
|
if (startInBlock || endInBlock) {
|
||||||
|
if (selectedText !== this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = selectedText;
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler: Text selected', {
|
||||||
|
text: selectedText,
|
||||||
|
blockId: block.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create range and get rect
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Dispatch event
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: selectedText.trim(),
|
||||||
|
blockId: block.id,
|
||||||
|
range: range,
|
||||||
|
rect: rect,
|
||||||
|
hasSelection: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (this.lastSelectedText) {
|
||||||
|
// Clear selection if no longer in this block
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for selection changes
|
||||||
|
document.addEventListener('selectionchange', checkSelection);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
this.selectionHandler = checkSelection;
|
||||||
|
|
||||||
|
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||||
|
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
if (wysiwygBlock) {
|
||||||
|
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||||
|
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||||
|
if (this.selectionHandler) {
|
||||||
|
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||||
|
this.selectionHandler = null;
|
||||||
|
}
|
||||||
|
if (originalDisconnectedCallback) {
|
||||||
|
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||||
|
}
|
||||||
|
}.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||||
|
const event = new CustomEvent('block-text-selected', {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
// Return styles for all heading levels
|
||||||
|
return `
|
||||||
|
.block.heading-1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 24px 0 8px 0;
|
||||||
|
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.heading-2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 20px 0 6px 0;
|
||||||
|
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.heading-3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 16px 0 4px 0;
|
||||||
|
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholder(): string {
|
||||||
|
switch(this.level) {
|
||||||
|
case 1:
|
||||||
|
return 'Heading 1';
|
||||||
|
case 2:
|
||||||
|
return 'Heading 2';
|
||||||
|
case 3:
|
||||||
|
return 'Heading 3';
|
||||||
|
default:
|
||||||
|
return 'Heading';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for heading functionality (mostly the same as paragraph)
|
||||||
|
|
||||||
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
|
// Get the actual heading element
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) {
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: No heading element found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: No selection found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: Range info:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
collapsed: selectionInfo.collapsed,
|
||||||
|
startContainerText: selectionInfo.startContainer.textContent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: Range not in element');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a range from start of element to cursor position
|
||||||
|
const preCaretRange = document.createRange();
|
||||||
|
preCaretRange.selectNodeContents(headingBlock);
|
||||||
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// Get the text content length up to cursor
|
||||||
|
const position = preCaretRange.toString().length;
|
||||||
|
console.log('HeadingBlockHandler.getCursorPosition: Calculated position:', {
|
||||||
|
position,
|
||||||
|
preCaretText: preCaretRange.toString(),
|
||||||
|
elementText: headingBlock.textContent,
|
||||||
|
elementTextLength: headingBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent(element: HTMLElement, context?: any): string {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) return '';
|
||||||
|
|
||||||
|
// For headings, get the innerHTML which includes formatting tags
|
||||||
|
const content = headingBlock.innerHTML || '';
|
||||||
|
console.log('HeadingBlockHandler.getContent:', content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) return;
|
||||||
|
|
||||||
|
// Store if we have focus
|
||||||
|
const hadFocus = document.activeElement === headingBlock ||
|
||||||
|
element.shadowRoot?.activeElement === headingBlock;
|
||||||
|
|
||||||
|
headingBlock.innerHTML = content;
|
||||||
|
|
||||||
|
// Restore focus if we had it
|
||||||
|
if (hadFocus) {
|
||||||
|
headingBlock.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (headingBlock) {
|
||||||
|
WysiwygBlocks.setCursorToStart(headingBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (headingBlock) {
|
||||||
|
WysiwygBlocks.setCursorToEnd(headingBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element: HTMLElement, context?: any): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) return;
|
||||||
|
|
||||||
|
// Ensure the element is focusable
|
||||||
|
if (!headingBlock.hasAttribute('contenteditable')) {
|
||||||
|
headingBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
headingBlock.focus();
|
||||||
|
|
||||||
|
// If focus failed, try again after a microtask
|
||||||
|
if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
headingBlock.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) return;
|
||||||
|
|
||||||
|
// Ensure element is focusable first
|
||||||
|
if (!headingBlock.hasAttribute('contenteditable')) {
|
||||||
|
headingBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the element
|
||||||
|
headingBlock.focus();
|
||||||
|
|
||||||
|
// Set cursor position after focus is established
|
||||||
|
const setCursor = () => {
|
||||||
|
if (position === 'start') {
|
||||||
|
this.setCursorToStart(element, context);
|
||||||
|
} else if (position === 'end') {
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
} else if (typeof position === 'number') {
|
||||||
|
// Use the selection utility to set cursor position
|
||||||
|
WysiwygSelection.setCursorPosition(headingBlock, position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cursor is set after focus
|
||||||
|
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||||
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Wait for focus to be established
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Starting...');
|
||||||
|
|
||||||
|
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||||
|
if (!headingBlock) {
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: No heading element found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Element info:', {
|
||||||
|
innerHTML: headingBlock.innerHTML,
|
||||||
|
textContent: headingBlock.textContent,
|
||||||
|
textLength: headingBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = headingBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Splitting with last known position:', {
|
||||||
|
pos,
|
||||||
|
fullTextLength: fullText.length,
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Selection range:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
startContainerInElement: headingBlock.contains(selectionInfo.startContainer)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure the selection is within this block
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = headingBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position first
|
||||||
|
const cursorPos = this.getCursorPosition(element, context);
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
|
||||||
|
|
||||||
|
if (cursorPos === null || cursorPos === 0) {
|
||||||
|
// If cursor is at start or can't determine position, move all content
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Cursor at start or null, moving all content');
|
||||||
|
return {
|
||||||
|
before: '',
|
||||||
|
after: headingBlock.innerHTML
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML content, split using ranges to preserve formatting
|
||||||
|
const beforeRange = document.createRange();
|
||||||
|
const afterRange = document.createRange();
|
||||||
|
|
||||||
|
// Before range: from start of element to cursor
|
||||||
|
beforeRange.setStart(headingBlock, 0);
|
||||||
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// After range: from cursor to end of element
|
||||||
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
afterRange.setEnd(headingBlock, headingBlock.childNodes.length);
|
||||||
|
|
||||||
|
// Extract HTML content
|
||||||
|
const beforeFragment = beforeRange.cloneContents();
|
||||||
|
const afterFragment = afterRange.cloneContents();
|
||||||
|
|
||||||
|
// Convert to HTML strings
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.appendChild(beforeFragment);
|
||||||
|
const beforeHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
tempDiv.innerHTML = '';
|
||||||
|
tempDiv.appendChild(afterFragment);
|
||||||
|
const afterHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
console.log('HeadingBlockHandler.getSplitContent: Final split result:', {
|
||||||
|
cursorPos,
|
||||||
|
beforeHtml,
|
||||||
|
beforeLength: beforeHtml.length,
|
||||||
|
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||||
|
afterHtml,
|
||||||
|
afterLength: afterHtml.length,
|
||||||
|
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: beforeHtml,
|
||||||
|
after: afterHtml
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
458
ts_web/elements/wysiwyg/blocks/text/list.block.ts
Normal file
458
ts_web/elements/wysiwyg/blocks/text/list.block.ts
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||||
|
|
||||||
|
export class ListBlockHandler extends BaseBlockHandler {
|
||||||
|
type = 'list';
|
||||||
|
|
||||||
|
// Track cursor position and list state
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
private lastSelectedText: string = '';
|
||||||
|
private selectionHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
const listType = block.metadata?.listType || 'unordered';
|
||||||
|
const listTag = listType === 'ordered' ? 'ol' : 'ul';
|
||||||
|
|
||||||
|
console.log('ListBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, listType });
|
||||||
|
|
||||||
|
// Render list content
|
||||||
|
const listContent = this.renderListContent(block.content, block.metadata);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div
|
||||||
|
class="block list${selectedClass}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-block-id="${block.id}"
|
||||||
|
data-block-type="${block.type}"
|
||||||
|
>${listContent}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderListContent(content: string | undefined, metadata: any): string {
|
||||||
|
if (!content) return '<ul><li></li></ul>';
|
||||||
|
|
||||||
|
const listType = metadata?.listType || 'unordered';
|
||||||
|
const listTag = listType === 'ordered' ? 'ol' : 'ul';
|
||||||
|
|
||||||
|
// Split content by newlines to create list items
|
||||||
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return `<${listTag}><li></li></${listTag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItems = lines.map(line => `<li>${line}</li>`).join('');
|
||||||
|
return `<${listTag}>${listItems}</${listTag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) {
|
||||||
|
console.error('ListBlockHandler.setup: No list block element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ListBlockHandler.setup: Setting up list block', { blockId: block.id });
|
||||||
|
|
||||||
|
// Set initial content if needed
|
||||||
|
if (block.content && !listBlock.innerHTML) {
|
||||||
|
listBlock.innerHTML = this.renderListContent(block.content, block.metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler
|
||||||
|
listBlock.addEventListener('input', (e) => {
|
||||||
|
console.log('ListBlockHandler: Input event', { blockId: block.id });
|
||||||
|
handlers.onInput(e as InputEvent);
|
||||||
|
|
||||||
|
// Track cursor position after input
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keydown handler
|
||||||
|
listBlock.addEventListener('keydown', (e) => {
|
||||||
|
// Track cursor position before keydown
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for Enter key in lists
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const currentLi = range.startContainer.parentElement?.closest('li');
|
||||||
|
|
||||||
|
if (currentLi && currentLi.textContent === '') {
|
||||||
|
// Empty list item - exit list mode
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, let browser create new list item naturally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus handler
|
||||||
|
listBlock.addEventListener('focus', () => {
|
||||||
|
console.log('ListBlockHandler: Focus event', { blockId: block.id });
|
||||||
|
handlers.onFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
listBlock.addEventListener('blur', () => {
|
||||||
|
console.log('ListBlockHandler: Blur event', { blockId: block.id });
|
||||||
|
handlers.onBlur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition handlers for IME support
|
||||||
|
listBlock.addEventListener('compositionstart', () => {
|
||||||
|
console.log('ListBlockHandler: Composition start', { blockId: block.id });
|
||||||
|
handlers.onCompositionStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
listBlock.addEventListener('compositionend', () => {
|
||||||
|
console.log('ListBlockHandler: Composition end', { blockId: block.id });
|
||||||
|
handlers.onCompositionEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
listBlock.addEventListener('mouseup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
handlers.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
listBlock.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyup handler
|
||||||
|
listBlock.addEventListener('keyup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up selection handler
|
||||||
|
this.setupSelectionHandler(element, listBlock, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSelectionHandler(element: HTMLElement, listBlock: HTMLDivElement, block: IBlock): void {
|
||||||
|
const checkSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
if (selectedText.length === 0) {
|
||||||
|
if (this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent wysiwyg component's shadow root
|
||||||
|
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||||
|
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
if (!selectionInfo) return;
|
||||||
|
|
||||||
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer);
|
||||||
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.endContainer);
|
||||||
|
|
||||||
|
if (startInBlock || endInBlock) {
|
||||||
|
if (selectedText !== this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = selectedText;
|
||||||
|
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: selectedText.trim(),
|
||||||
|
blockId: block.id,
|
||||||
|
range: range,
|
||||||
|
rect: rect,
|
||||||
|
hasSelection: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', checkSelection);
|
||||||
|
this.selectionHandler = checkSelection;
|
||||||
|
|
||||||
|
// Cleanup on disconnect
|
||||||
|
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
if (wysiwygBlock) {
|
||||||
|
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||||
|
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||||
|
if (this.selectionHandler) {
|
||||||
|
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||||
|
this.selectionHandler = null;
|
||||||
|
}
|
||||||
|
if (originalDisconnectedCallback) {
|
||||||
|
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||||
|
}
|
||||||
|
}.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||||
|
const event = new CustomEvent('block-text-selected', {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
return `
|
||||||
|
/* List specific styles */
|
||||||
|
.block.list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.list ul,
|
||||||
|
.block.list ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.list li {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block.list li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholder(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for list functionality
|
||||||
|
|
||||||
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return null;
|
||||||
|
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
if (!selectionInfo) return null;
|
||||||
|
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For lists, calculate position based on text content
|
||||||
|
const preCaretRange = document.createRange();
|
||||||
|
preCaretRange.selectNodeContents(listBlock);
|
||||||
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
return preCaretRange.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent(element: HTMLElement, context?: any): string {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return '';
|
||||||
|
|
||||||
|
// Extract text content from list items
|
||||||
|
const listItems = listBlock.querySelectorAll('li');
|
||||||
|
const content = Array.from(listItems)
|
||||||
|
.map(li => li.textContent || '')
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
console.log('ListBlockHandler.getContent:', content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return;
|
||||||
|
|
||||||
|
const hadFocus = document.activeElement === listBlock ||
|
||||||
|
element.shadowRoot?.activeElement === listBlock;
|
||||||
|
|
||||||
|
// Get current metadata to preserve list type
|
||||||
|
const listElement = listBlock.querySelector('ul, ol');
|
||||||
|
const isOrdered = listElement?.tagName === 'OL';
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
listBlock.innerHTML = this.renderListContent(content, { listType: isOrdered ? 'ordered' : 'unordered' });
|
||||||
|
|
||||||
|
if (hadFocus) {
|
||||||
|
listBlock.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return;
|
||||||
|
|
||||||
|
const firstLi = listBlock.querySelector('li');
|
||||||
|
if (firstLi) {
|
||||||
|
const textNode = this.getFirstTextNode(firstLi);
|
||||||
|
if (textNode) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
range.setStart(textNode, 0);
|
||||||
|
range.setEnd(textNode, 0);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return;
|
||||||
|
|
||||||
|
const lastLi = listBlock.querySelector('li:last-child');
|
||||||
|
if (lastLi) {
|
||||||
|
const textNode = this.getLastTextNode(lastLi);
|
||||||
|
if (textNode) {
|
||||||
|
const range = document.createRange();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const textLength = textNode.textContent?.length || 0;
|
||||||
|
range.setStart(textNode, textLength);
|
||||||
|
range.setEnd(textNode, textLength);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFirstTextNode(element: Node): Text | null {
|
||||||
|
if (element.nodeType === Node.TEXT_NODE) {
|
||||||
|
return element as Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < element.childNodes.length; i++) {
|
||||||
|
const firstText = this.getFirstTextNode(element.childNodes[i]);
|
||||||
|
if (firstText) return firstText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element: HTMLElement, context?: any): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return;
|
||||||
|
|
||||||
|
if (!listBlock.hasAttribute('contenteditable')) {
|
||||||
|
listBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
listBlock.focus();
|
||||||
|
|
||||||
|
if (document.activeElement !== listBlock && element.shadowRoot?.activeElement !== listBlock) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
listBlock.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return;
|
||||||
|
|
||||||
|
if (!listBlock.hasAttribute('contenteditable')) {
|
||||||
|
listBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
listBlock.focus();
|
||||||
|
|
||||||
|
const setCursor = () => {
|
||||||
|
if (position === 'start') {
|
||||||
|
this.setCursorToStart(element, context);
|
||||||
|
} else if (position === 'end') {
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
} else if (typeof position === 'number') {
|
||||||
|
// For numeric positions in lists, we need custom logic
|
||||||
|
// This is complex due to list structure, so default to end
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
|
||||||
|
setCursor();
|
||||||
|
} else {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||||
|
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||||
|
if (!listBlock) return null;
|
||||||
|
|
||||||
|
// For lists, we don't split content - instead let the keyboard handler
|
||||||
|
// create a new paragraph block when Enter is pressed on empty list item
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
538
ts_web/elements/wysiwyg/blocks/text/paragraph.block.ts
Normal file
538
ts_web/elements/wysiwyg/blocks/text/paragraph.block.ts
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||||
|
|
||||||
|
export class ParagraphBlockHandler extends BaseBlockHandler {
|
||||||
|
type = 'paragraph';
|
||||||
|
|
||||||
|
// Track cursor position
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
private lastSelectedText: string = '';
|
||||||
|
private selectionHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
const placeholder = this.getPlaceholder();
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.render:', { blockId: block.id, isSelected, content: block.content });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div
|
||||||
|
class="block paragraph${selectedClass}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-placeholder="${placeholder}"
|
||||||
|
data-block-id="${block.id}"
|
||||||
|
data-block-type="${block.type}"
|
||||||
|
>${block.content || ''}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) {
|
||||||
|
console.error('ParagraphBlockHandler.setup: No paragraph block element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.setup: Setting up paragraph block', { blockId: block.id });
|
||||||
|
|
||||||
|
// Set initial content if needed
|
||||||
|
if (block.content && !paragraphBlock.innerHTML) {
|
||||||
|
paragraphBlock.innerHTML = block.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler with cursor tracking
|
||||||
|
paragraphBlock.addEventListener('input', (e) => {
|
||||||
|
console.log('ParagraphBlockHandler: Input event', { blockId: block.id });
|
||||||
|
handlers.onInput(e as InputEvent);
|
||||||
|
|
||||||
|
// Track cursor position after input
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('ParagraphBlockHandler: Updated cursor position after input', { pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keydown handler with cursor tracking
|
||||||
|
paragraphBlock.addEventListener('keydown', (e) => {
|
||||||
|
// Track cursor position before keydown
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('ParagraphBlockHandler: Cursor position before keydown', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus handler
|
||||||
|
paragraphBlock.addEventListener('focus', () => {
|
||||||
|
console.log('ParagraphBlockHandler: Focus event', { blockId: block.id });
|
||||||
|
handlers.onFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
paragraphBlock.addEventListener('blur', () => {
|
||||||
|
console.log('ParagraphBlockHandler: Blur event', { blockId: block.id });
|
||||||
|
handlers.onBlur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition handlers for IME support
|
||||||
|
paragraphBlock.addEventListener('compositionstart', () => {
|
||||||
|
console.log('ParagraphBlockHandler: Composition start', { blockId: block.id });
|
||||||
|
handlers.onCompositionStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
paragraphBlock.addEventListener('compositionend', () => {
|
||||||
|
console.log('ParagraphBlockHandler: Composition end', { blockId: block.id });
|
||||||
|
handlers.onCompositionEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
paragraphBlock.addEventListener('mouseup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('ParagraphBlockHandler: Cursor position after mouseup', { pos });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection will be handled by selectionchange event
|
||||||
|
handlers.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler with delayed cursor tracking
|
||||||
|
paragraphBlock.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
// Small delay to let browser set cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('ParagraphBlockHandler: Cursor position after click', { pos });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyup handler for additional cursor tracking
|
||||||
|
paragraphBlock.addEventListener('keyup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('ParagraphBlockHandler: Cursor position after keyup', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up selection change handler
|
||||||
|
this.setupSelectionHandler(element, paragraphBlock, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSelectionHandler(element: HTMLElement, paragraphBlock: HTMLDivElement, block: IBlock): void {
|
||||||
|
// Add selection change handler
|
||||||
|
const checkSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
if (selectedText.length === 0) {
|
||||||
|
// Clear selection if no text
|
||||||
|
if (this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent wysiwyg component's shadow root - traverse from shadow root
|
||||||
|
const wysiwygBlock = (paragraphBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||||
|
|
||||||
|
// Use getComposedRanges with shadow roots as per MDN docs
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
// Get selection info using our Shadow DOM-aware utility
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
if (!selectionInfo) return;
|
||||||
|
|
||||||
|
// Check if selection is within this block
|
||||||
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer);
|
||||||
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.endContainer);
|
||||||
|
|
||||||
|
if (startInBlock || endInBlock) {
|
||||||
|
if (selectedText !== this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = selectedText;
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler: Text selected', {
|
||||||
|
text: selectedText,
|
||||||
|
blockId: block.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create range and get rect
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Dispatch event
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: selectedText.trim(),
|
||||||
|
blockId: block.id,
|
||||||
|
range: range,
|
||||||
|
rect: rect,
|
||||||
|
hasSelection: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (this.lastSelectedText) {
|
||||||
|
// Clear selection if no longer in this block
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for selection changes
|
||||||
|
document.addEventListener('selectionchange', checkSelection);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
this.selectionHandler = checkSelection;
|
||||||
|
|
||||||
|
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||||
|
const wysiwygBlock = element.closest('dees-wysiwyg-block');
|
||||||
|
if (wysiwygBlock) {
|
||||||
|
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||||
|
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||||
|
if (this.selectionHandler) {
|
||||||
|
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||||
|
this.selectionHandler = null;
|
||||||
|
}
|
||||||
|
if (originalDisconnectedCallback) {
|
||||||
|
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||||
|
}
|
||||||
|
}.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||||
|
const event = new CustomEvent('block-text-selected', {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
return `
|
||||||
|
/* Paragraph specific styles */
|
||||||
|
.block.paragraph {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholder(): string {
|
||||||
|
return "Type '/' for commands...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for paragraph functionality
|
||||||
|
|
||||||
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: Called with element:', element, 'context:', context);
|
||||||
|
|
||||||
|
// Get the actual paragraph element
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) {
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: No paragraph element found');
|
||||||
|
console.log('Element innerHTML:', element.innerHTML);
|
||||||
|
console.log('Element tagName:', element.tagName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length,
|
||||||
|
element: element,
|
||||||
|
paragraphBlock: paragraphBlock
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: No selection found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: Range info:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
collapsed: selectionInfo.collapsed,
|
||||||
|
startContainerText: selectionInfo.startContainer.textContent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: Range not in element');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a range from start of element to cursor position
|
||||||
|
const preCaretRange = document.createRange();
|
||||||
|
preCaretRange.selectNodeContents(paragraphBlock);
|
||||||
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// Get the text content length up to cursor
|
||||||
|
const position = preCaretRange.toString().length;
|
||||||
|
console.log('ParagraphBlockHandler.getCursorPosition: Calculated position:', {
|
||||||
|
position,
|
||||||
|
preCaretText: preCaretRange.toString(),
|
||||||
|
elementText: paragraphBlock.textContent,
|
||||||
|
elementTextLength: paragraphBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent(element: HTMLElement, context?: any): string {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) return '';
|
||||||
|
|
||||||
|
// For paragraphs, get the innerHTML which includes formatting tags
|
||||||
|
const content = paragraphBlock.innerHTML || '';
|
||||||
|
console.log('ParagraphBlockHandler.getContent:', content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) return;
|
||||||
|
|
||||||
|
// Store if we have focus
|
||||||
|
const hadFocus = document.activeElement === paragraphBlock ||
|
||||||
|
element.shadowRoot?.activeElement === paragraphBlock;
|
||||||
|
|
||||||
|
paragraphBlock.innerHTML = content;
|
||||||
|
|
||||||
|
// Restore focus if we had it
|
||||||
|
if (hadFocus) {
|
||||||
|
paragraphBlock.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (paragraphBlock) {
|
||||||
|
WysiwygBlocks.setCursorToStart(paragraphBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (paragraphBlock) {
|
||||||
|
WysiwygBlocks.setCursorToEnd(paragraphBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element: HTMLElement, context?: any): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) return;
|
||||||
|
|
||||||
|
// Ensure the element is focusable
|
||||||
|
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
||||||
|
paragraphBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraphBlock.focus();
|
||||||
|
|
||||||
|
// If focus failed, try again after a microtask
|
||||||
|
if (document.activeElement !== paragraphBlock && element.shadowRoot?.activeElement !== paragraphBlock) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
paragraphBlock.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) return;
|
||||||
|
|
||||||
|
// Ensure element is focusable first
|
||||||
|
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
||||||
|
paragraphBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the element
|
||||||
|
paragraphBlock.focus();
|
||||||
|
|
||||||
|
// Set cursor position after focus is established
|
||||||
|
const setCursor = () => {
|
||||||
|
if (position === 'start') {
|
||||||
|
this.setCursorToStart(element, context);
|
||||||
|
} else if (position === 'end') {
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
} else if (typeof position === 'number') {
|
||||||
|
// Use the selection utility to set cursor position
|
||||||
|
WysiwygSelection.setCursorPosition(paragraphBlock, position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cursor is set after focus
|
||||||
|
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||||
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Wait for focus to be established
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Starting...');
|
||||||
|
|
||||||
|
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||||
|
if (!paragraphBlock) {
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: No paragraph element found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Element info:', {
|
||||||
|
innerHTML: paragraphBlock.innerHTML,
|
||||||
|
textContent: paragraphBlock.textContent,
|
||||||
|
textLength: paragraphBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = paragraphBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Splitting with last known position:', {
|
||||||
|
pos,
|
||||||
|
fullTextLength: fullText.length,
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Selection range:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
startContainerInElement: paragraphBlock.contains(selectionInfo.startContainer)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure the selection is within this block
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = paragraphBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position first
|
||||||
|
const cursorPos = this.getCursorPosition(element, context);
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
|
||||||
|
|
||||||
|
if (cursorPos === null || cursorPos === 0) {
|
||||||
|
// If cursor is at start or can't determine position, move all content
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Cursor at start or null, moving all content');
|
||||||
|
return {
|
||||||
|
before: '',
|
||||||
|
after: paragraphBlock.innerHTML
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML content, split using ranges to preserve formatting
|
||||||
|
const beforeRange = document.createRange();
|
||||||
|
const afterRange = document.createRange();
|
||||||
|
|
||||||
|
// Before range: from start of element to cursor
|
||||||
|
beforeRange.setStart(paragraphBlock, 0);
|
||||||
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// After range: from cursor to end of element
|
||||||
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
afterRange.setEnd(paragraphBlock, paragraphBlock.childNodes.length);
|
||||||
|
|
||||||
|
// Extract HTML content
|
||||||
|
const beforeFragment = beforeRange.cloneContents();
|
||||||
|
const afterFragment = afterRange.cloneContents();
|
||||||
|
|
||||||
|
// Convert to HTML strings
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.appendChild(beforeFragment);
|
||||||
|
const beforeHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
tempDiv.innerHTML = '';
|
||||||
|
tempDiv.appendChild(afterFragment);
|
||||||
|
const afterHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
console.log('ParagraphBlockHandler.getSplitContent: Final split result:', {
|
||||||
|
cursorPos,
|
||||||
|
beforeHtml,
|
||||||
|
beforeLength: beforeHtml.length,
|
||||||
|
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||||
|
afterHtml,
|
||||||
|
afterLength: afterHtml.length,
|
||||||
|
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: beforeHtml,
|
||||||
|
after: afterHtml
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
541
ts_web/elements/wysiwyg/blocks/text/quote.block.ts
Normal file
541
ts_web/elements/wysiwyg/blocks/text/quote.block.ts
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||||
|
import type { IBlock } from '../../wysiwyg.types.js';
|
||||||
|
import { cssManager } from '@design.estate/dees-element';
|
||||||
|
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||||
|
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||||
|
|
||||||
|
export class QuoteBlockHandler extends BaseBlockHandler {
|
||||||
|
type = 'quote';
|
||||||
|
|
||||||
|
// Track cursor position
|
||||||
|
private lastKnownCursorPosition: number = 0;
|
||||||
|
private lastSelectedText: string = '';
|
||||||
|
private selectionHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
render(block: IBlock, isSelected: boolean): string {
|
||||||
|
const selectedClass = isSelected ? ' selected' : '';
|
||||||
|
const placeholder = this.getPlaceholder();
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.render:', { blockId: block.id, isSelected, content: block.content });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div
|
||||||
|
class="block quote${selectedClass}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-placeholder="${placeholder}"
|
||||||
|
data-block-id="${block.id}"
|
||||||
|
data-block-type="${block.type}"
|
||||||
|
>${block.content || ''}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) {
|
||||||
|
console.error('QuoteBlockHandler.setup: No quote block element found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.setup: Setting up quote block', { blockId: block.id });
|
||||||
|
|
||||||
|
// Set initial content if needed
|
||||||
|
if (block.content && !quoteBlock.innerHTML) {
|
||||||
|
quoteBlock.innerHTML = block.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input handler with cursor tracking
|
||||||
|
quoteBlock.addEventListener('input', (e) => {
|
||||||
|
console.log('QuoteBlockHandler: Input event', { blockId: block.id });
|
||||||
|
handlers.onInput(e as InputEvent);
|
||||||
|
|
||||||
|
// Track cursor position after input
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('QuoteBlockHandler: Updated cursor position after input', { pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keydown handler with cursor tracking
|
||||||
|
quoteBlock.addEventListener('keydown', (e) => {
|
||||||
|
// Track cursor position before keydown
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('QuoteBlockHandler: Cursor position before keydown', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onKeyDown(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus handler
|
||||||
|
quoteBlock.addEventListener('focus', () => {
|
||||||
|
console.log('QuoteBlockHandler: Focus event', { blockId: block.id });
|
||||||
|
handlers.onFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blur handler
|
||||||
|
quoteBlock.addEventListener('blur', () => {
|
||||||
|
console.log('QuoteBlockHandler: Blur event', { blockId: block.id });
|
||||||
|
handlers.onBlur();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Composition handlers for IME support
|
||||||
|
quoteBlock.addEventListener('compositionstart', () => {
|
||||||
|
console.log('QuoteBlockHandler: Composition start', { blockId: block.id });
|
||||||
|
handlers.onCompositionStart();
|
||||||
|
});
|
||||||
|
|
||||||
|
quoteBlock.addEventListener('compositionend', () => {
|
||||||
|
console.log('QuoteBlockHandler: Composition end', { blockId: block.id });
|
||||||
|
handlers.onCompositionEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up handler
|
||||||
|
quoteBlock.addEventListener('mouseup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('QuoteBlockHandler: Cursor position after mouseup', { pos });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection will be handled by selectionchange event
|
||||||
|
handlers.onMouseUp?.(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler with delayed cursor tracking
|
||||||
|
quoteBlock.addEventListener('click', (e: MouseEvent) => {
|
||||||
|
// Small delay to let browser set cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('QuoteBlockHandler: Cursor position after click', { pos });
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyup handler for additional cursor tracking
|
||||||
|
quoteBlock.addEventListener('keyup', (e) => {
|
||||||
|
const pos = this.getCursorPosition(element);
|
||||||
|
if (pos !== null) {
|
||||||
|
this.lastKnownCursorPosition = pos;
|
||||||
|
console.log('QuoteBlockHandler: Cursor position after keyup', { pos, key: e.key });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up selection change handler
|
||||||
|
this.setupSelectionHandler(element, quoteBlock, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSelectionHandler(element: HTMLElement, quoteBlock: HTMLDivElement, block: IBlock): void {
|
||||||
|
// Add selection change handler
|
||||||
|
const checkSelection = () => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return;
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
if (selectedText.length === 0) {
|
||||||
|
// Clear selection if no text
|
||||||
|
if (this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent wysiwyg component's shadow root - traverse from shadow root
|
||||||
|
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||||
|
|
||||||
|
// Use getComposedRanges with shadow roots as per MDN docs
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
// Get selection info using our Shadow DOM-aware utility
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
if (!selectionInfo) return;
|
||||||
|
|
||||||
|
// Check if selection is within this block
|
||||||
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer);
|
||||||
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.endContainer);
|
||||||
|
|
||||||
|
if (startInBlock || endInBlock) {
|
||||||
|
if (selectedText !== this.lastSelectedText) {
|
||||||
|
this.lastSelectedText = selectedText;
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler: Text selected', {
|
||||||
|
text: selectedText,
|
||||||
|
blockId: block.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create range and get rect
|
||||||
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Dispatch event
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: selectedText.trim(),
|
||||||
|
blockId: block.id,
|
||||||
|
range: range,
|
||||||
|
rect: rect,
|
||||||
|
hasSelection: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (this.lastSelectedText) {
|
||||||
|
// Clear selection if no longer in this block
|
||||||
|
this.lastSelectedText = '';
|
||||||
|
this.dispatchSelectionEvent(element, {
|
||||||
|
text: '',
|
||||||
|
blockId: block.id,
|
||||||
|
hasSelection: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for selection changes
|
||||||
|
document.addEventListener('selectionchange', checkSelection);
|
||||||
|
|
||||||
|
// Store the handler for cleanup
|
||||||
|
this.selectionHandler = checkSelection;
|
||||||
|
|
||||||
|
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||||
|
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
|
||||||
|
if (wysiwygBlock) {
|
||||||
|
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||||
|
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||||
|
if (this.selectionHandler) {
|
||||||
|
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||||
|
this.selectionHandler = null;
|
||||||
|
}
|
||||||
|
if (originalDisconnectedCallback) {
|
||||||
|
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||||
|
}
|
||||||
|
}.bind(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||||
|
const event = new CustomEvent('block-text-selected', {
|
||||||
|
detail,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
});
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStyles(): string {
|
||||||
|
return `
|
||||||
|
/* Quote specific styles */
|
||||||
|
.block.quote {
|
||||||
|
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||||
|
padding-left: 20px;
|
||||||
|
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholder(): string {
|
||||||
|
return 'Add a quote...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for quote functionality
|
||||||
|
|
||||||
|
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: Called with element:', element, 'context:', context);
|
||||||
|
|
||||||
|
// Get the actual quote element
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) {
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: No quote element found');
|
||||||
|
console.log('Element innerHTML:', element.innerHTML);
|
||||||
|
console.log('Element tagName:', element.tagName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length,
|
||||||
|
element: element,
|
||||||
|
quoteBlock: quoteBlock
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: No selection found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: Range info:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
collapsed: selectionInfo.collapsed,
|
||||||
|
startContainerText: selectionInfo.startContainer.textContent
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: Range not in element');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a range from start of element to cursor position
|
||||||
|
const preCaretRange = document.createRange();
|
||||||
|
preCaretRange.selectNodeContents(quoteBlock);
|
||||||
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// Get the text content length up to cursor
|
||||||
|
const position = preCaretRange.toString().length;
|
||||||
|
console.log('QuoteBlockHandler.getCursorPosition: Calculated position:', {
|
||||||
|
position,
|
||||||
|
preCaretText: preCaretRange.toString(),
|
||||||
|
elementText: quoteBlock.textContent,
|
||||||
|
elementTextLength: quoteBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent(element: HTMLElement, context?: any): string {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) return '';
|
||||||
|
|
||||||
|
// For quotes, get the innerHTML which includes formatting tags
|
||||||
|
const content = quoteBlock.innerHTML || '';
|
||||||
|
console.log('QuoteBlockHandler.getContent:', content);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) return;
|
||||||
|
|
||||||
|
// Store if we have focus
|
||||||
|
const hadFocus = document.activeElement === quoteBlock ||
|
||||||
|
element.shadowRoot?.activeElement === quoteBlock;
|
||||||
|
|
||||||
|
quoteBlock.innerHTML = content;
|
||||||
|
|
||||||
|
// Restore focus if we had it
|
||||||
|
if (hadFocus) {
|
||||||
|
quoteBlock.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (quoteBlock) {
|
||||||
|
WysiwygBlocks.setCursorToStart(quoteBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (quoteBlock) {
|
||||||
|
WysiwygBlocks.setCursorToEnd(quoteBlock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(element: HTMLElement, context?: any): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) return;
|
||||||
|
|
||||||
|
// Ensure the element is focusable
|
||||||
|
if (!quoteBlock.hasAttribute('contenteditable')) {
|
||||||
|
quoteBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteBlock.focus();
|
||||||
|
|
||||||
|
// If focus failed, try again after a microtask
|
||||||
|
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
quoteBlock.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) return;
|
||||||
|
|
||||||
|
// Ensure element is focusable first
|
||||||
|
if (!quoteBlock.hasAttribute('contenteditable')) {
|
||||||
|
quoteBlock.setAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the element
|
||||||
|
quoteBlock.focus();
|
||||||
|
|
||||||
|
// Set cursor position after focus is established
|
||||||
|
const setCursor = () => {
|
||||||
|
if (position === 'start') {
|
||||||
|
this.setCursorToStart(element, context);
|
||||||
|
} else if (position === 'end') {
|
||||||
|
this.setCursorToEnd(element, context);
|
||||||
|
} else if (typeof position === 'number') {
|
||||||
|
// Use the selection utility to set cursor position
|
||||||
|
WysiwygSelection.setCursorPosition(quoteBlock, position);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cursor is set after focus
|
||||||
|
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
|
||||||
|
setCursor();
|
||||||
|
} else {
|
||||||
|
// Wait for focus to be established
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
|
||||||
|
setCursor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Starting...');
|
||||||
|
|
||||||
|
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||||
|
if (!quoteBlock) {
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: No quote element found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Element info:', {
|
||||||
|
innerHTML: quoteBlock.innerHTML,
|
||||||
|
textContent: quoteBlock.textContent,
|
||||||
|
textLength: quoteBlock.textContent?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get shadow roots from context
|
||||||
|
const wysiwygBlock = context?.component;
|
||||||
|
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||||
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||||
|
const blockShadowRoot = context?.shadowRoot;
|
||||||
|
|
||||||
|
// Get selection info with both shadow roots for proper traversal
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||||
|
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||||
|
|
||||||
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Selection info from shadow DOMs:', {
|
||||||
|
selectionInfo,
|
||||||
|
shadowRootsCount: shadowRoots.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectionInfo) {
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = quoteBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Splitting with last known position:', {
|
||||||
|
pos,
|
||||||
|
fullTextLength: fullText.length,
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Selection range:', {
|
||||||
|
startContainer: selectionInfo.startContainer,
|
||||||
|
startOffset: selectionInfo.startOffset,
|
||||||
|
startContainerInElement: quoteBlock.contains(selectionInfo.startContainer)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure the selection is within this block
|
||||||
|
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
||||||
|
// Try using last known cursor position
|
||||||
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
const fullText = quoteBlock.textContent || '';
|
||||||
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||||
|
return {
|
||||||
|
before: fullText.substring(0, pos),
|
||||||
|
after: fullText.substring(pos)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cursor position first
|
||||||
|
const cursorPos = this.getCursorPosition(element, context);
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos);
|
||||||
|
|
||||||
|
if (cursorPos === null || cursorPos === 0) {
|
||||||
|
// If cursor is at start or can't determine position, move all content
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Cursor at start or null, moving all content');
|
||||||
|
return {
|
||||||
|
before: '',
|
||||||
|
after: quoteBlock.innerHTML
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML content, split using ranges to preserve formatting
|
||||||
|
const beforeRange = document.createRange();
|
||||||
|
const afterRange = document.createRange();
|
||||||
|
|
||||||
|
// Before range: from start of element to cursor
|
||||||
|
beforeRange.setStart(quoteBlock, 0);
|
||||||
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
|
||||||
|
// After range: from cursor to end of element
|
||||||
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||||
|
afterRange.setEnd(quoteBlock, quoteBlock.childNodes.length);
|
||||||
|
|
||||||
|
// Extract HTML content
|
||||||
|
const beforeFragment = beforeRange.cloneContents();
|
||||||
|
const afterFragment = afterRange.cloneContents();
|
||||||
|
|
||||||
|
// Convert to HTML strings
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.appendChild(beforeFragment);
|
||||||
|
const beforeHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
tempDiv.innerHTML = '';
|
||||||
|
tempDiv.appendChild(afterFragment);
|
||||||
|
const afterHtml = tempDiv.innerHTML;
|
||||||
|
|
||||||
|
console.log('QuoteBlockHandler.getSplitContent: Final split result:', {
|
||||||
|
cursorPos,
|
||||||
|
beforeHtml,
|
||||||
|
beforeLength: beforeHtml.length,
|
||||||
|
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||||
|
afterHtml,
|
||||||
|
afterLength: afterHtml.length,
|
||||||
|
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
before: beforeHtml,
|
||||||
|
after: afterHtml
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,8 @@ import {
|
|||||||
import { type IBlock } from './wysiwyg.types.js';
|
import { type IBlock } from './wysiwyg.types.js';
|
||||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||||
|
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
|
||||||
|
import './wysiwyg.blockregistration.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@ -34,15 +36,7 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
public isSelected: boolean = false;
|
public isSelected: boolean = false;
|
||||||
|
|
||||||
@property({ type: Object })
|
@property({ type: Object })
|
||||||
public handlers: {
|
public handlers: IBlockEventHandlers;
|
||||||
onInput: (e: InputEvent) => void;
|
|
||||||
onKeyDown: (e: KeyboardEvent) => void;
|
|
||||||
onFocus: () => void;
|
|
||||||
onBlur: () => void;
|
|
||||||
onCompositionStart: () => void;
|
|
||||||
onCompositionEnd: () => void;
|
|
||||||
onMouseUp?: (e: MouseEvent) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reference to the editable block element
|
// Reference to the editable block element
|
||||||
private blockElement: HTMLDivElement | null = null;
|
private blockElement: HTMLDivElement | null = null;
|
||||||
@ -54,6 +48,31 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
private lastKnownCursorPosition: number = 0;
|
private lastKnownCursorPosition: number = 0;
|
||||||
private lastSelectedText: string = '';
|
private lastSelectedText: string = '';
|
||||||
|
|
||||||
|
private static handlerStylesInjected = false;
|
||||||
|
|
||||||
|
private injectHandlerStyles(): void {
|
||||||
|
// Only inject once per component class
|
||||||
|
if (DeesWysiwygBlock.handlerStylesInjected) return;
|
||||||
|
DeesWysiwygBlock.handlerStylesInjected = true;
|
||||||
|
|
||||||
|
// Get styles from all registered block handlers
|
||||||
|
let styles = '';
|
||||||
|
const blockTypes = BlockRegistry.getAllTypes();
|
||||||
|
for (const type of blockTypes) {
|
||||||
|
const handler = BlockRegistry.getHandler(type);
|
||||||
|
if (handler) {
|
||||||
|
styles += handler.getStyles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles) {
|
||||||
|
// Create and inject style element
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.textContent = styles;
|
||||||
|
this.shadowRoot?.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
@ -141,30 +160,6 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block.divider {
|
|
||||||
padding: 8px 0;
|
|
||||||
margin: 16px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block.divider:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block.divider.selected {
|
|
||||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
|
|
||||||
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.block.divider hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
||||||
margin: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Formatting styles */
|
/* Formatting styles */
|
||||||
.block :is(b, strong) {
|
.block :is(b, strong) {
|
||||||
@ -722,7 +717,7 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
// Never update if only the block content changed
|
// Never update if only the block content changed
|
||||||
if (changedProperties.has('block') && this.block) {
|
if (changedProperties.has('block') && this.block) {
|
||||||
const oldBlock = changedProperties.get('block');
|
const oldBlock = changedProperties.get('block');
|
||||||
if (oldBlock && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
|
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
|
||||||
// Only content or metadata changed, don't re-render
|
// Only content or metadata changed, don't re-render
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -736,19 +731,31 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
// Mark that content has been initialized
|
// Mark that content has been initialized
|
||||||
this.contentInitialized = true;
|
this.contentInitialized = true;
|
||||||
|
|
||||||
|
// Inject handler styles if not already done
|
||||||
|
this.injectHandlerStyles();
|
||||||
|
|
||||||
// First, populate the container with the rendered content
|
// First, populate the container with the rendered content
|
||||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
||||||
if (container && this.block) {
|
if (container && this.block) {
|
||||||
container.innerHTML = this.renderBlockContent();
|
container.innerHTML = this.renderBlockContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
if (this.block) {
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler) {
|
||||||
|
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
if (blockElement) {
|
||||||
|
handler.setup(blockElement, this.block, this.handlers);
|
||||||
|
}
|
||||||
|
return; // Block handler takes care of all setup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle special block types
|
// Handle special block types
|
||||||
if (this.block.type === 'image') {
|
if (this.block.type === 'image') {
|
||||||
this.setupImageBlock();
|
this.setupImageBlock();
|
||||||
return; // Image blocks don't need the standard editable setup
|
return; // Image blocks don't need the standard editable setup
|
||||||
} else if (this.block.type === 'divider') {
|
|
||||||
this.setupDividerBlock();
|
|
||||||
return; // Divider blocks don't need the standard editable setup
|
|
||||||
} else if (this.block.type === 'youtube') {
|
} else if (this.block.type === 'youtube') {
|
||||||
this.setupYouTubeBlock();
|
this.setupYouTubeBlock();
|
||||||
return;
|
return;
|
||||||
@ -875,8 +882,8 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
if (!selectionInfo) return;
|
if (!selectionInfo) return;
|
||||||
|
|
||||||
// Check if selection is within this block
|
// Check if selection is within this block
|
||||||
const startInBlock = currentEditableBlock.contains(selectionInfo.startContainer);
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.startContainer);
|
||||||
const endInBlock = currentEditableBlock.contains(selectionInfo.endContainer);
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer);
|
||||||
|
|
||||||
if (startInBlock || endInBlock) {
|
if (startInBlock || endInBlock) {
|
||||||
if (selectedText !== this.lastSelectedText) {
|
if (selectedText !== this.lastSelectedText) {
|
||||||
@ -956,13 +963,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
private renderBlockContent(): string {
|
private renderBlockContent(): string {
|
||||||
if (!this.block) return '';
|
if (!this.block) return '';
|
||||||
|
|
||||||
if (this.block.type === 'divider') {
|
// Check if we have a registered handler for this block type
|
||||||
const selectedClass = this.isSelected ? ' selected' : '';
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
return `
|
if (handler) {
|
||||||
<div class="block divider${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
return handler.render(this.block, this.isSelected);
|
||||||
<hr>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.block.type === 'code') {
|
if (this.block.type === 'code') {
|
||||||
@ -1145,6 +1149,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
|
|
||||||
|
|
||||||
public focus(): void {
|
public focus(): void {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.focus) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.focus(container, context);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle non-editable blocks
|
// Handle non-editable blocks
|
||||||
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
||||||
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
||||||
@ -1178,6 +1190,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.focusWithCursor) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.focusWithCursor(container, position, context);
|
||||||
|
}
|
||||||
|
|
||||||
// Non-editable blocks don't support cursor positioning
|
// Non-editable blocks don't support cursor positioning
|
||||||
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
||||||
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
||||||
@ -1231,6 +1251,13 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
* Get cursor position in the editable element
|
* Get cursor position in the editable element
|
||||||
*/
|
*/
|
||||||
public getCursorPosition(element: HTMLElement): number | null {
|
public getCursorPosition(element: HTMLElement): number | null {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.getCursorPosition) {
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.getCursorPosition(element, context);
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
@ -1281,6 +1308,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getContent(): string {
|
public getContent(): string {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.getContent) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.getContent(container, context);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle image blocks specially
|
// Handle image blocks specially
|
||||||
if (this.block?.type === 'image') {
|
if (this.block?.type === 'image') {
|
||||||
return this.block.content || ''; // Image blocks store alt text in content
|
return this.block.content || ''; // Image blocks store alt text in content
|
||||||
@ -1307,6 +1342,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setContent(content: string): void {
|
public setContent(content: string): void {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.setContent) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.setContent(container, content, context);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the actual editable element (might be nested for code blocks)
|
// Get the actual editable element (might be nested for code blocks)
|
||||||
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
|
||||||
@ -1332,6 +1375,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setCursorToStart(): void {
|
public setCursorToStart(): void {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.setCursorToStart) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.setCursorToStart(container, context);
|
||||||
|
}
|
||||||
|
|
||||||
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.blockElement;
|
||||||
@ -1341,6 +1392,14 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setCursorToEnd(): void {
|
public setCursorToEnd(): void {
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
if (handler && handler.setCursorToEnd) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||||
|
return handler.setCursorToEnd(container, context);
|
||||||
|
}
|
||||||
|
|
||||||
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.blockElement;
|
||||||
@ -1358,43 +1417,6 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup divider block functionality
|
|
||||||
*/
|
|
||||||
private setupDividerBlock(): void {
|
|
||||||
const dividerBlock = this.shadowRoot?.querySelector('.block.divider') as HTMLDivElement;
|
|
||||||
if (!dividerBlock) return;
|
|
||||||
|
|
||||||
// Handle click to select
|
|
||||||
dividerBlock.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Focus will trigger the selection
|
|
||||||
dividerBlock.focus();
|
|
||||||
// Ensure focus handler is called immediately
|
|
||||||
this.handlers?.onFocus?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle focus/blur
|
|
||||||
dividerBlock.addEventListener('focus', () => {
|
|
||||||
this.handlers?.onFocus?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
dividerBlock.addEventListener('blur', () => {
|
|
||||||
this.handlers?.onBlur?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle keyboard events
|
|
||||||
dividerBlock.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
||||||
e.preventDefault();
|
|
||||||
// Let the keyboard handler in the parent component handle the deletion
|
|
||||||
this.handlers?.onKeyDown?.(e);
|
|
||||||
} else {
|
|
||||||
// Handle navigation keys
|
|
||||||
this.handlers?.onKeyDown?.(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup YouTube block functionality
|
* Setup YouTube block functionality
|
||||||
@ -1988,6 +2010,27 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
public getSplitContent(): { before: string; after: string } | null {
|
public getSplitContent(): { before: string; after: string } | null {
|
||||||
console.log('getSplitContent: Starting...');
|
console.log('getSplitContent: Starting...');
|
||||||
|
|
||||||
|
// Check if we have a registered handler for this block type
|
||||||
|
const handler = BlockRegistry.getHandler(this.block.type);
|
||||||
|
console.log('getSplitContent: Checking for handler', {
|
||||||
|
blockType: this.block.type,
|
||||||
|
hasHandler: !!handler,
|
||||||
|
hasSplitMethod: !!(handler && handler.getSplitContent)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handler && handler.getSplitContent) {
|
||||||
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||||
|
console.log('getSplitContent: Found container', {
|
||||||
|
container: !!container,
|
||||||
|
containerHTML: container?.innerHTML?.substring(0, 100)
|
||||||
|
});
|
||||||
|
const context = {
|
||||||
|
shadowRoot: this.shadowRoot!,
|
||||||
|
component: this
|
||||||
|
};
|
||||||
|
return handler.getSplitContent(container, context);
|
||||||
|
}
|
||||||
|
|
||||||
// Image blocks can't be split
|
// Image blocks can't be split
|
||||||
if (this.block?.type === 'image') {
|
if (this.block?.type === 'image') {
|
||||||
return null;
|
return null;
|
||||||
@ -2052,7 +2095,7 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Make sure the selection is within this block
|
// Make sure the selection is within this block
|
||||||
if (!editableElement.contains(selectionInfo.startContainer)) {
|
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
|
||||||
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
||||||
// Try using last known cursor position
|
// Try using last known cursor position
|
||||||
if (this.lastKnownCursorPosition !== null) {
|
if (this.lastKnownCursorPosition !== null) {
|
||||||
|
61
ts_web/elements/wysiwyg/phase2-summary.md
Normal file
61
ts_web/elements/wysiwyg/phase2-summary.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Phase 2 Implementation Summary - Divider Block Migration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully migrated the divider block to the new block handler architecture as a proof of concept.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Created Block Handler
|
||||||
|
- **File**: `blocks/content/divider.block.ts`
|
||||||
|
- Implemented `DividerBlockHandler` class extending `BaseBlockHandler`
|
||||||
|
- Extracted divider rendering logic from `dees-wysiwyg-block.ts`
|
||||||
|
- Extracted divider setup logic (event handlers)
|
||||||
|
- Extracted divider-specific styles
|
||||||
|
|
||||||
|
### 2. Registration System
|
||||||
|
- **File**: `wysiwyg.blockregistration.ts`
|
||||||
|
- Created registration module that registers all block handlers
|
||||||
|
- Currently registers only the divider handler
|
||||||
|
- Includes placeholders for future block types
|
||||||
|
|
||||||
|
### 3. Updated Block Component
|
||||||
|
- **File**: `dees-wysiwyg-block.ts`
|
||||||
|
- Added import for BlockRegistry and handler types
|
||||||
|
- Modified `renderBlockContent()` to check registry first
|
||||||
|
- Modified `firstUpdated()` to use registry for setup
|
||||||
|
- Added `injectHandlerStyles()` method to inject handler styles dynamically
|
||||||
|
- Removed hardcoded divider rendering logic
|
||||||
|
- Removed hardcoded divider styles
|
||||||
|
- Removed `setupDividerBlock()` method
|
||||||
|
|
||||||
|
### 4. Updated Exports
|
||||||
|
- **File**: `blocks/index.ts`
|
||||||
|
- Exported `DividerBlockHandler` class
|
||||||
|
|
||||||
|
## Key Features Preserved
|
||||||
|
✅ Visual appearance with gradient and icon
|
||||||
|
✅ Click to select behavior
|
||||||
|
✅ Keyboard navigation support (Tab, Arrow keys)
|
||||||
|
✅ Deletion with backspace/delete
|
||||||
|
✅ Focus/blur handling
|
||||||
|
✅ Proper styling for selected state
|
||||||
|
|
||||||
|
## Architecture Benefits
|
||||||
|
1. **Modularity**: Each block type is now self-contained
|
||||||
|
2. **Maintainability**: Block-specific logic is isolated
|
||||||
|
3. **Extensibility**: Easy to add new block types
|
||||||
|
4. **Type Safety**: Proper TypeScript interfaces
|
||||||
|
5. **Code Reuse**: Common functionality in BaseBlockHandler
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
To migrate other block types, follow this pattern:
|
||||||
|
1. Create handler file in appropriate folder (text/, media/, content/)
|
||||||
|
2. Extract render logic, setup logic, and styles
|
||||||
|
3. Register in `wysiwyg.blockregistration.ts`
|
||||||
|
4. Remove hardcoded logic from `dees-wysiwyg-block.ts`
|
||||||
|
5. Export from `blocks/index.ts`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Project builds successfully without errors
|
||||||
|
- Existing tests pass
|
||||||
|
- Divider blocks render and function identically to before
|
75
ts_web/elements/wysiwyg/phase4-summary.md
Normal file
75
ts_web/elements/wysiwyg/phase4-summary.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Phase 4 Implementation Summary - Heading Blocks Migration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Successfully migrated all heading blocks (h1, h2, h3) to the new block handler architecture using a unified HeadingBlockHandler class.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Created Unified Heading Handler
|
||||||
|
- **File**: `blocks/text/heading.block.ts`
|
||||||
|
- Implemented `HeadingBlockHandler` class extending `BaseBlockHandler`
|
||||||
|
- Single handler class that accepts heading level (1, 2, or 3) in constructor
|
||||||
|
- Extracted all heading rendering logic from `dees-wysiwyg-block.ts`
|
||||||
|
- Extracted heading setup logic with full text editing support:
|
||||||
|
- Input handling with cursor tracking
|
||||||
|
- Selection handling with Shadow DOM support
|
||||||
|
- Focus/blur management
|
||||||
|
- Composition events for IME support
|
||||||
|
- Split content functionality
|
||||||
|
- Extracted all heading-specific styles for all three levels
|
||||||
|
|
||||||
|
### 2. Registration Updates
|
||||||
|
- **File**: `wysiwyg.blockregistration.ts`
|
||||||
|
- Registered three heading handlers using the same class:
|
||||||
|
- `heading-1` → `new HeadingBlockHandler('heading-1')`
|
||||||
|
- `heading-2` → `new HeadingBlockHandler('heading-2')`
|
||||||
|
- `heading-3` → `new HeadingBlockHandler('heading-3')`
|
||||||
|
- Updated imports to include HeadingBlockHandler
|
||||||
|
|
||||||
|
### 3. Updated Exports
|
||||||
|
- **File**: `blocks/index.ts`
|
||||||
|
- Exported `HeadingBlockHandler` class
|
||||||
|
- Removed TODO comment for heading handler
|
||||||
|
|
||||||
|
### 4. Handler Implementation Details
|
||||||
|
- **Dynamic Level Handling**: The handler determines the heading level from the block type
|
||||||
|
- **Shared Styles**: All heading levels share the same style method but render different CSS
|
||||||
|
- **Placeholder Support**: Each level has its own placeholder text
|
||||||
|
- **Full Text Editing**: Inherits all paragraph-like functionality:
|
||||||
|
- Cursor position tracking
|
||||||
|
- Text selection with Shadow DOM awareness
|
||||||
|
- Content splitting for Enter key handling
|
||||||
|
- Focus management with cursor positioning
|
||||||
|
|
||||||
|
## Key Features Preserved
|
||||||
|
✅ All three heading levels render with correct styles
|
||||||
|
✅ Font sizes: h1 (32px), h2 (24px), h3 (20px)
|
||||||
|
✅ Proper font weights and line heights
|
||||||
|
✅ Theme-aware colors using cssManager.bdTheme
|
||||||
|
✅ Contenteditable functionality
|
||||||
|
✅ Selection and cursor tracking
|
||||||
|
✅ Keyboard navigation
|
||||||
|
✅ Focus/blur handling
|
||||||
|
✅ Placeholder text for empty headings
|
||||||
|
|
||||||
|
## Architecture Benefits
|
||||||
|
1. **Code Reuse**: Single handler class for all heading levels
|
||||||
|
2. **Consistency**: All headings share the same behavior
|
||||||
|
3. **Maintainability**: Changes to heading behavior only need to be made once
|
||||||
|
4. **Type Safety**: Heading level is type-checked at construction
|
||||||
|
5. **Scalability**: Easy to add more heading levels if needed
|
||||||
|
|
||||||
|
## Testing Results
|
||||||
|
- ✅ TypeScript compilation successful
|
||||||
|
- ✅ All three heading handlers registered correctly
|
||||||
|
- ✅ Render method produces correct HTML with proper classes
|
||||||
|
- ✅ Placeholders set correctly for each level
|
||||||
|
- ✅ All handlers are instances of HeadingBlockHandler
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
Continue with Phase 5 to migrate remaining text blocks:
|
||||||
|
- Quote block
|
||||||
|
- Code block
|
||||||
|
- List block
|
||||||
|
|
||||||
|
Each will follow the same pattern but with their specific requirements.
|
47
ts_web/elements/wysiwyg/wysiwyg.blockregistration.ts
Normal file
47
ts_web/elements/wysiwyg/wysiwyg.blockregistration.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Block Registration Module
|
||||||
|
* Handles registration of all block handlers with the BlockRegistry
|
||||||
|
*
|
||||||
|
* Phase 2 Complete: Divider block has been successfully migrated
|
||||||
|
* to the new block handler architecture.
|
||||||
|
* Phase 3 Complete: Paragraph block has been successfully migrated
|
||||||
|
* to the new block handler architecture.
|
||||||
|
* Phase 4 Complete: All heading blocks (h1, h2, h3) have been successfully migrated
|
||||||
|
* to the new block handler architecture using a unified HeadingBlockHandler.
|
||||||
|
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
|
||||||
|
* to the new block handler architecture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BlockRegistry, DividerBlockHandler } from './blocks/index.js';
|
||||||
|
import { ParagraphBlockHandler } from './blocks/text/paragraph.block.js';
|
||||||
|
import { HeadingBlockHandler } from './blocks/text/heading.block.js';
|
||||||
|
import { QuoteBlockHandler } from './blocks/text/quote.block.js';
|
||||||
|
import { CodeBlockHandler } from './blocks/text/code.block.js';
|
||||||
|
import { ListBlockHandler } from './blocks/text/list.block.js';
|
||||||
|
|
||||||
|
// Initialize and register all block handlers
|
||||||
|
export function registerAllBlockHandlers(): void {
|
||||||
|
// Register content blocks
|
||||||
|
BlockRegistry.register('divider', new DividerBlockHandler());
|
||||||
|
|
||||||
|
// Register text blocks
|
||||||
|
BlockRegistry.register('paragraph', new ParagraphBlockHandler());
|
||||||
|
BlockRegistry.register('heading-1', new HeadingBlockHandler('heading-1'));
|
||||||
|
BlockRegistry.register('heading-2', new HeadingBlockHandler('heading-2'));
|
||||||
|
BlockRegistry.register('heading-3', new HeadingBlockHandler('heading-3'));
|
||||||
|
BlockRegistry.register('quote', new QuoteBlockHandler());
|
||||||
|
BlockRegistry.register('code', new CodeBlockHandler());
|
||||||
|
BlockRegistry.register('list', new ListBlockHandler());
|
||||||
|
|
||||||
|
// TODO: Register media blocks when implemented
|
||||||
|
// BlockRegistry.register('image', new ImageBlockHandler());
|
||||||
|
// BlockRegistry.register('youtube', new YoutubeBlockHandler());
|
||||||
|
// BlockRegistry.register('attachment', new AttachmentBlockHandler());
|
||||||
|
|
||||||
|
// TODO: Register other content blocks when implemented
|
||||||
|
// BlockRegistry.register('markdown', new MarkdownBlockHandler());
|
||||||
|
// BlockRegistry.register('html', new HtmlBlockHandler());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure blocks are registered when this module is imported
|
||||||
|
registerAllBlockHandlers();
|
@ -242,4 +242,38 @@ export class WysiwygSelection {
|
|||||||
this.setSelectionFromRange(range);
|
this.setSelectionFromRange(range);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node is contained within an element across Shadow DOM boundaries
|
||||||
|
* This is needed because element.contains() doesn't work across Shadow DOM
|
||||||
|
*/
|
||||||
|
static containsAcrossShadowDOM(container: Node, node: Node): boolean {
|
||||||
|
if (!container || !node) return false;
|
||||||
|
|
||||||
|
// Start with the node and traverse up
|
||||||
|
let current: Node | null = node;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
// Direct match
|
||||||
|
if (current === container) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're at a shadow root, check its host
|
||||||
|
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (current as any).host) {
|
||||||
|
const shadowRoot = current as ShadowRoot;
|
||||||
|
// Check if the container is within this shadow root
|
||||||
|
if (shadowRoot.contains(container)) {
|
||||||
|
return false; // Container is in a child shadow DOM
|
||||||
|
}
|
||||||
|
// Move to the host element
|
||||||
|
current = shadowRoot.host;
|
||||||
|
} else {
|
||||||
|
// Regular DOM traversal
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user