Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 113c013ea9 | |||
| 0571d5bf4b | |||
| 5f86fdba72 | |||
| 474385a939 | |||
| 71d64fccb8 | |||
| e9541da8ff | |||
| 68b4e9ec8e | |||
| 856d354b5a | |||
| 89a4a15e78 | |||
| fca3638f7f | |||
| 90fc8bed35 | |||
| bd223f77d0 | |||
| 1041814823 | |||
| 366544befc | |||
| 3b93bd63a7 | |||
| ca525ce7e3 | |||
| 83f153f654 | |||
| 75637c7793 | |||
| e0a125c9bd | |||
| 4b2178cedd | |||
| 08a4c361fa | |||
| 35a648d450 | |||
| 1c76ade150 | |||
| 8b02c5aea3 | |||
| c82c407350 | |||
| 169f74aa2e | |||
| e4a042907a | |||
| 7ce282c500 | |||
| 302777feff | |||
| cdcd4f79c8 | |||
| f2e6342a61 | |||
| 0f02e7d00f | |||
| a1079cbbdd | |||
| 58af08cb0d | |||
| 6626726029 | |||
| f3afbb2e48 | |||
| fbd52ee9a5 | |||
| 3284d91c2a | |||
| 22e6b74c4f | |||
| 4de835474b | |||
| 024d8af40d | |||
| 808b74fa17 | |||
| 202881ef1a | |||
| 7de3d451ad | |||
| f0e0430016 | |||
| 873579fc97 | |||
| d321db363d |
17
changelog.md
17
changelog.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-06-22 - 1.9.0 - feat(form-inputs)
|
||||
Improve form input consistency and auto spacing across inputs and buttons
|
||||
|
||||
- Add an 'insideForm' property to dees-button for auto-detection and proper margin adjustment in forms.
|
||||
- Update dees-input-radio to include a 'name' property so that radio buttons in the same group are mutually exclusive.
|
||||
- Enhance dees-form to group radio inputs properly when collecting form data.
|
||||
- Revise readme.hints.md and readme.plan.md to document changes and provide guidance for dees-input-radio.
|
||||
- Update demos for dees-button and dees-form to showcase correct spacing in vertical and horizontal layouts.
|
||||
|
||||
## 2025-06-20 - 1.8.20 - fix(deps)
|
||||
Update dependency versions: bump @design.estate/dees-domtools from ^2.1.1 to ^2.3.3, @design.estate/dees-element from ^2.0.42 to ^2.0.44, lucide from ^0.515.0 to ^0.518.0, and @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||
|
||||
- Upgrade @design.estate/dees-domtools from ^2.1.1 to ^2.3.3
|
||||
- Upgrade @design.estate/dees-element from ^2.0.42 to ^2.0.44
|
||||
- Upgrade lucide from ^0.515.0 to ^0.518.0
|
||||
- Upgrade @git.zone/tsbundle from ^2.0.15 to ^2.4.0
|
||||
|
||||
## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
|
||||
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.
|
||||
|
||||
|
||||
12
package.json
12
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.8.16",
|
||||
"version": "1.9.2",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
"typings": "dist_ts_web/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/ --web",
|
||||
"test": "tstest test/ --web --verbose --timeout 30",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
|
||||
"watch": "tswatch element",
|
||||
"buildDocs": "tsdoc"
|
||||
@@ -15,8 +15,8 @@
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@design.estate/dees-domtools": "^2.1.1",
|
||||
"@design.estate/dees-element": "^2.0.42",
|
||||
"@design.estate/dees-domtools": "^2.3.3",
|
||||
"@design.estate/dees-element": "^2.0.45",
|
||||
"@design.estate/dees-wcctools": "^1.0.98",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"apexcharts": "^4.7.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"ibantools": "^4.5.1",
|
||||
"lucide": "^0.515.0",
|
||||
"lucide": "^0.522.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"xterm": "^5.3.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsbundle": "^2.0.15",
|
||||
"@git.zone/tsbundle": "^2.4.0",
|
||||
"@git.zone/tstest": "^2.3.1",
|
||||
"@git.zone/tswatch": "^2.0.37",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
|
||||
860
pnpm-lock.yaml
generated
860
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
455
readme.hints.md
455
readme.hints.md
@@ -59,3 +59,458 @@
|
||||
- Action grouping
|
||||
- Navigation options
|
||||
- Filter controls
|
||||
|
||||
## Form Components
|
||||
|
||||
### dees-input-radio
|
||||
- Radio button component with proper group behavior
|
||||
- Properties:
|
||||
- `name`: Group name for mutually exclusive selection
|
||||
- `key`: Unique identifier for the radio option
|
||||
- `value`: Boolean indicating selection state
|
||||
- `label`: Display label
|
||||
- Features:
|
||||
- Automatic group management (radios with same name are mutually exclusive)
|
||||
- Cannot be deselected by clicking (proper radio behavior)
|
||||
- Form integration: Radio groups are collected by name, value is the selected radio's key
|
||||
- Works both inside and outside forms
|
||||
- Supports disabled state
|
||||
- Fixed: Radio buttons now properly deselect others in the group on first click
|
||||
- Note: When using in forms, set both `name` (for grouping) and `key` (for the value)
|
||||
|
||||
## WYSIWYG Editor Architecture
|
||||
|
||||
### Recent Refactoring (2025-06-24)
|
||||
|
||||
The WYSIWYG editor has been refactored to improve maintainability and separation of concerns:
|
||||
|
||||
#### New Handler Classes
|
||||
|
||||
1. **WysiwygBlockOperations** (`wysiwyg.blockoperations.ts`)
|
||||
- Manages all block-related operations
|
||||
- Methods: createBlock, insertBlockAfter, removeBlock, findBlock, focusBlock, etc.
|
||||
- Centralized block manipulation logic
|
||||
|
||||
2. **WysiwygInputHandler** (`wysiwyg.inputhandler.ts`)
|
||||
- Handles all input events for blocks
|
||||
- Manages block content updates based on type
|
||||
- Detects block type transformations
|
||||
- Handles slash commands
|
||||
- Manages auto-save with debouncing
|
||||
|
||||
3. **WysiwygKeyboardHandler** (`wysiwyg.keyboardhandler.ts`)
|
||||
- Handles all keyboard events
|
||||
- Manages formatting shortcuts (Cmd/Ctrl + B/I/U/K)
|
||||
- Handles special keys: Tab, Enter, Backspace
|
||||
- Manages slash menu navigation
|
||||
|
||||
4. **WysiwygDragDropHandler** (`wysiwyg.dragdrophandler.ts`)
|
||||
- Manages drag and drop operations
|
||||
- Tracks drag state
|
||||
- Handles visual feedback during drag
|
||||
- Manages block reordering
|
||||
|
||||
5. **WysiwygModalManager** (`wysiwyg.modalmanager.ts`)
|
||||
- Static methods for showing modals
|
||||
- Language selection for code blocks
|
||||
- Block settings modal
|
||||
- Reusable modal patterns
|
||||
|
||||
#### Main Component Updates
|
||||
|
||||
The main `DeesInputWysiwyg` component now:
|
||||
- Instantiates handler classes in `connectedCallback`
|
||||
- Delegates complex operations to appropriate handlers
|
||||
- Maintains cleaner, more focused code
|
||||
- Better separation of concerns
|
||||
|
||||
#### Benefits
|
||||
- Reduced main component size from 1100+ lines
|
||||
- Each handler class is focused on a single responsibility
|
||||
- Easier to test individual components
|
||||
- Better code organization
|
||||
- Improved maintainability
|
||||
|
||||
#### Fixed Issues
|
||||
- Enter key no longer duplicates content in new blocks
|
||||
- Removed problematic `setBlockContents()` method
|
||||
- Content is now managed directly through DOM properties
|
||||
- Better timing for block creation and focus
|
||||
- Slash menu no longer disappears immediately on first "/" press
|
||||
- Focus is properly maintained when slash menu opens
|
||||
- Removed duplicate event handling methods from main component
|
||||
- Simplified focus management throughout the editor
|
||||
|
||||
#### Additional Refactoring (2025-06-24 - Part 2)
|
||||
- **Removed duplicate code**: handleBlockInput and handleBlockKeyDown methods removed from main component
|
||||
- **Simplified focus management**: Removed complex lifecycle methods and timers
|
||||
- **Fixed slash menu behavior**: Changed to click events and proper event prevention
|
||||
- **dees-wysiwyg-block component**: Now uses static HTML rendering for better content control
|
||||
- **Improved formatting preservation**: HTML formatting (bold, italic, etc.) properly preserved in all block types
|
||||
|
||||
#### Notes
|
||||
- All input handling now goes through WysiwygInputHandler
|
||||
- All keyboard handling goes through WysiwygKeyboardHandler
|
||||
- The slash menu uses click events instead of mousedown for better UX
|
||||
- Focus is maintained using requestAnimationFrame for better timing
|
||||
- The refactoring maintains all existing functionality with improved reliability
|
||||
|
||||
### Global Menu Architecture (2025-06-24 - Part 3)
|
||||
|
||||
The slash menu and formatting menu have been refactored to render globally instead of inside the wysiwyg component. This fixes focus loss issues that were occurring when the menus were re-rendered with the component.
|
||||
|
||||
#### Key Components:
|
||||
|
||||
1. **DeesSlashMenu** (`dees-slash-menu.ts`)
|
||||
- Singleton component that renders globally in the document body
|
||||
- Accessed via `DeesSlashMenu.getInstance()`
|
||||
- Manages its own visibility, position, and filtering
|
||||
- Emits callbacks when items are selected
|
||||
|
||||
2. **DeesFormattingMenu** (`dees-formatting-menu.ts`)
|
||||
- Singleton component that renders globally in the document body
|
||||
- Accessed via `DeesFormattingMenu.getInstance()`
|
||||
- Shows when text is selected
|
||||
- Applies formatting commands via callback
|
||||
|
||||
3. **Integration in DeesInputWysiwyg**
|
||||
- Stores singleton instances: `private slashMenu = DeesSlashMenu.getInstance()`
|
||||
- Shows menus with absolute positioning
|
||||
- Menus handle their own rendering and state management
|
||||
|
||||
#### Benefits:
|
||||
- No focus loss when menus appear/disappear
|
||||
- Better performance (menus don't re-render with component)
|
||||
- Cleaner separation of concerns
|
||||
- Menus persist across component updates
|
||||
|
||||
#### Usage:
|
||||
```typescript
|
||||
// Show slash menu
|
||||
this.slashMenu.show(
|
||||
{ x: cursorX, y: cursorY },
|
||||
(type: string) => this.insertBlock(type)
|
||||
);
|
||||
|
||||
// Show formatting menu
|
||||
this.formattingMenu.show(
|
||||
{ x: selectionX, y: selectionY },
|
||||
(command: string) => this.applyFormat(command)
|
||||
);
|
||||
```
|
||||
|
||||
#### Previous Issues Fixed:
|
||||
- Slash menu was disappearing immediately on first "/" press
|
||||
- Focus was lost when menus appeared
|
||||
- Text selection was not working properly
|
||||
- Cursor position was lost after menu interactions
|
||||
|
||||
### Arrow Key Navigation (2025-06-24 - Part 4)
|
||||
|
||||
Enhanced arrow key handling for seamless navigation between blocks:
|
||||
|
||||
#### Features:
|
||||
1. **ArrowUp at block start**: Automatically navigates to the end of the previous block
|
||||
2. **ArrowDown at block end**: Automatically navigates to the beginning of the next block
|
||||
3. **Smart detection**: Checks actual cursor position within the block content
|
||||
4. **Slash menu integration**: When slash menu is open, arrow keys navigate menu items instead
|
||||
5. **No focus loss**: Navigation maintains focus throughout
|
||||
|
||||
#### Implementation:
|
||||
- Added `handleArrowUp()` and `handleArrowDown()` methods to `WysiwygKeyboardHandler`
|
||||
- Smart cursor position detection for different block types (text, lists, etc.)
|
||||
- Helper method `getLastTextNode()` for finding the last text position in complex HTML
|
||||
- Prevents default behavior only when navigating between blocks
|
||||
- Skips divider blocks during navigation
|
||||
|
||||
### Focus Management Improvements (2025-06-24 - Part 5)
|
||||
|
||||
Enhanced focus management to prevent focus loss during various operations:
|
||||
|
||||
#### Key Improvements:
|
||||
|
||||
1. **Formatting Without execCommand**:
|
||||
- Replaced deprecated `document.execCommand` with modern DOM manipulation
|
||||
- Proper selection restoration after formatting
|
||||
- Async formatting operations to maintain focus
|
||||
|
||||
2. **Link Dialog**:
|
||||
- Replaced `prompt()` with custom modal dialog
|
||||
- Maintains focus context during async operations
|
||||
- Auto-focuses input field in modal
|
||||
|
||||
3. **Robust Focus Methods**:
|
||||
- Double `requestAnimationFrame` for DOM update timing
|
||||
- Fallback focus attempts with microtasks
|
||||
- Contenteditable attribute verification
|
||||
|
||||
4. **Cursor Positioning**:
|
||||
- Enhanced `setCursorToStart/End` with edge case handling
|
||||
- Zero-width space insertion for empty elements
|
||||
- Recursive node traversal for complex HTML structures
|
||||
|
||||
5. **Async Keyboard Shortcuts**:
|
||||
- Formatting shortcuts use Promise resolution
|
||||
- Prevents focus loss during rapid keyboard input
|
||||
|
||||
#### Implementation Details:
|
||||
- `focusWithCursor()` method now handles empty blocks and complex HTML
|
||||
- `applyFormat()` is async and properly restores selection
|
||||
- Link creation no longer uses blocking `prompt()` dialog
|
||||
- All focus operations use proper timing with RAF and microtasks
|
||||
|
||||
### Focus Loss Prevention for Menus (2025-06-24 - Part 6)
|
||||
|
||||
Fixed focus loss issues when slash menu and formatting menu appear:
|
||||
|
||||
#### Key Fixes:
|
||||
|
||||
1. **Timeout Reduction**:
|
||||
- Replaced 50ms setTimeout with requestAnimationFrame
|
||||
- Immediate focus attempt before falling back to RAF
|
||||
- Reduced delay when inserting blocks
|
||||
|
||||
2. **Menu Focus Prevention**:
|
||||
- Added `tabindex="-1"` to prevent menus from taking focus
|
||||
- Added focus event prevention on menus
|
||||
- Menus now use mousedown prevention consistently
|
||||
|
||||
3. **Blur Event Handling**:
|
||||
- Skip value updates when slash menu is visible
|
||||
- Prevent auto-save during slash menu interaction
|
||||
- Maintain focus after menu appears with RAF
|
||||
|
||||
4. **Block Focus Optimization**:
|
||||
- Try immediate focus if block element exists
|
||||
- Fall back to RAF only when necessary
|
||||
- Consistent focus handling across all block types
|
||||
|
||||
#### Implementation:
|
||||
- `handleBlockBlur()` checks if slash menu is visible before updating
|
||||
- `scheduleAutoSave()` skips saving when slash menu is open
|
||||
- Slash menu show adds RAF to restore focus if lost
|
||||
- Reduced timing delays throughout the focus chain
|
||||
|
||||
### Slash Command Cleanup (2025-06-24 - Part 7)
|
||||
|
||||
Fixed the issue where "/" remained in the editor after selecting a block type:
|
||||
|
||||
#### The Fix:
|
||||
|
||||
1. **In `insertBlock()`**:
|
||||
- Clear slash command before transforming block type
|
||||
- Use regex `/^\/[^\s]*\s*/` to match slash + filter text
|
||||
- Trim the result to ensure clean content
|
||||
- Set content to empty for transformed blocks
|
||||
|
||||
2. **Improved Content Handling**:
|
||||
- Wait for `updateComplete` before focusing
|
||||
- Ensure lists start with empty content
|
||||
- Consistent cleanup in both `insertBlock` and `closeSlashMenu`
|
||||
|
||||
3. **Edge Cases**:
|
||||
- Handle filtered commands (e.g., "/hea" for heading)
|
||||
- Clear content even with partial matches
|
||||
- Proper content reset for all block types
|
||||
|
||||
Now when selecting a block type from the slash menu, the "/" and any filter text is properly removed before the block transformation occurs.
|
||||
|
||||
### Enhanced Enter Key and Block Settings (2025-06-24 - Part 8)
|
||||
|
||||
Added two major improvements to the wysiwyg editor:
|
||||
|
||||
#### 1. Smart Enter Key Behavior:
|
||||
|
||||
When pressing Enter, content after the cursor is now moved to the next block:
|
||||
|
||||
- **Content Splitting**: Uses Range API to extract content after cursor
|
||||
- **HTML Preservation**: Maintains formatting when splitting blocks
|
||||
- **Clean Split**: Current block keeps content before cursor, new block gets content after
|
||||
- **Empty Block**: If cursor is at end, creates empty new block
|
||||
|
||||
Implementation in `WysiwygKeyboardHandler.handleEnter()`:
|
||||
```typescript
|
||||
// Clone the range to extract content after cursor
|
||||
const afterRange = range.cloneRange();
|
||||
afterRange.selectNodeContents(target);
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
|
||||
// Extract content after cursor
|
||||
const afterContent = afterRange.extractContents();
|
||||
```
|
||||
|
||||
#### 2. Block Type Changing via Settings Menu:
|
||||
|
||||
The block settings menu (three dots) now includes block type selection:
|
||||
|
||||
- **Type Selector Grid**: Shows all available block types with icons
|
||||
- **Smart Metadata Handling**:
|
||||
- Clears code language when changing from code block
|
||||
- Clears list type when changing from list
|
||||
- Prompts for language when changing to code block
|
||||
- **Visual Feedback**: Currently selected type is highlighted
|
||||
- **Instant Update**: Block transforms immediately on selection
|
||||
|
||||
Features:
|
||||
- Works for all block types (not just code blocks)
|
||||
- Preserves content during type transformation
|
||||
- Handles special cases like code block language selection
|
||||
- Modal closes automatically after selection
|
||||
|
||||
### Complete WYSIWYG Refactoring (2025-06-24 - Part 9)
|
||||
|
||||
Major architectural improvements to fix Enter key behavior and left arrow focus loss:
|
||||
|
||||
#### 1. Async Operation Architecture:
|
||||
- All focus operations are now async with proper Promise handling
|
||||
- `insertBlockAfter()` waits for component updates before focusing
|
||||
- `focusBlock()` ensures DOM is ready with `updateComplete`
|
||||
- Eliminated arbitrary timeouts in favor of proper async/await
|
||||
|
||||
#### 2. Enter Key Split Content Fix:
|
||||
- Added `getSplitContent()` method to block component
|
||||
- Properly extracts content before/after cursor using Range API
|
||||
- Updates current block and creates new block atomically
|
||||
- Content after cursor correctly moves to new block
|
||||
|
||||
```typescript
|
||||
// In block component
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
const beforeRange = range.cloneRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
// ... extract and return split content
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Arrow Key Navigation:
|
||||
- Added ArrowLeft/ArrowRight handlers for block boundaries
|
||||
- Prevents focus loss when navigating between blocks
|
||||
- Only intercepts at block boundaries, normal navigation otherwise
|
||||
- All arrow key operations are async for proper timing
|
||||
|
||||
#### 4. Interface Architecture:
|
||||
Created `wysiwyg.interfaces.ts` with proper typing:
|
||||
- `IWysiwygComponent` - Main component contract
|
||||
- `IBlockOperations` - Block operation methods
|
||||
- `IWysiwygBlockComponent` - Block component interface
|
||||
- `IBlockEventHandlers` - Event handler signatures
|
||||
|
||||
#### 5. Focus Management Improvements:
|
||||
- Eliminated double RAF in favor of single async flow
|
||||
- Focus operations wait for DOM updates via `updateComplete`
|
||||
- Proper cursor positioning after all operations
|
||||
- No more focus loss during navigation
|
||||
|
||||
#### Key Changes:
|
||||
1. Keyboard handler methods are now async
|
||||
2. Block operations return Promises
|
||||
3. Enter key properly splits content at cursor
|
||||
4. Arrow keys handle block navigation without focus loss
|
||||
5. All timing is handled via proper async/await patterns
|
||||
|
||||
The refactoring eliminates race conditions and timing issues that were causing focus loss and content duplication problems.
|
||||
|
||||
### Programmatic Rendering Solution (2025-06-24 - Part 10)
|
||||
|
||||
Fixed persistent focus loss issue by implementing fully programmatic rendering:
|
||||
|
||||
#### The Problem:
|
||||
- User would click in a block, type text, then press arrow keys and lose focus
|
||||
- Root cause: Lit was re-rendering components when block content was mutated
|
||||
- Even with shouldUpdate() preventing re-renders, parent re-evaluation caused focus loss
|
||||
|
||||
#### The Solution:
|
||||
|
||||
1. **Static Parent Rendering**:
|
||||
- Parent component renders only once with empty editor content div
|
||||
- All blocks are created and managed programmatically via DOM manipulation
|
||||
- No Lit re-renders triggered by state changes
|
||||
|
||||
2. **Manual Block Management**:
|
||||
- `renderBlocksProgrammatically()` creates all block elements manually
|
||||
- `createBlockElement()` builds block wrapper with all event handlers
|
||||
- `updateBlockElement()` replaces individual blocks when needed
|
||||
- No reactive properties trigger parent re-renders
|
||||
|
||||
3. **Content Update Strategy**:
|
||||
- During typing, content is NOT immediately synced to data model
|
||||
- Auto-save delayed to 2 seconds to avoid interference
|
||||
- Content synced from DOM only on blur or before save
|
||||
- `syncAllBlockContent()` reads from DOM when needed
|
||||
|
||||
4. **Focus Preservation**:
|
||||
- Block components prevent re-renders with `shouldUpdate()`
|
||||
- Parent never re-renders after initial load
|
||||
- Focus remains stable during all editing operations
|
||||
- Arrow key navigation works without focus loss
|
||||
|
||||
5. **Implementation Details**:
|
||||
```typescript
|
||||
// Parent render method - static after first render
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="editor-content" id="editor-content">
|
||||
<!-- Blocks rendered programmatically -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// All block operations use DOM manipulation
|
||||
private renderBlocksProgrammatically() {
|
||||
this.editorContentRef.innerHTML = '';
|
||||
this.blocks.forEach(block => {
|
||||
const blockWrapper = this.createBlockElement(block);
|
||||
this.editorContentRef.appendChild(blockWrapper);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This approach completely eliminates focus loss by taking full control of the DOM and preventing any framework-induced re-renders during editing.
|
||||
|
||||
### Code Refactoring and Cleanup (2025-06-24 - Part 11)
|
||||
|
||||
Completed comprehensive refactoring to ensure clean, maintainable code with separated concerns:
|
||||
|
||||
#### Refactoring Changes:
|
||||
|
||||
1. **Drag and Drop Handler Cleanup**:
|
||||
- Removed all `requestUpdate()` calls from drag handler
|
||||
- Handler now only updates internal state
|
||||
- Parent component handles DOM updates programmatically
|
||||
- Simplified drag state management
|
||||
|
||||
2. **Unused Code Removal**:
|
||||
- Removed duplicate `showBlockSettingsModal` method (using WysiwygModalManager)
|
||||
- Removed duplicate `showLanguageSelectionModal` method
|
||||
- Removed unused `renderBlock` method
|
||||
- Cleaned up unused imports (WysiwygBlocks, ISlashMenuItem)
|
||||
|
||||
3. **Import Cleanup**:
|
||||
- Removed unused type imports
|
||||
- Organized imports logically
|
||||
- Kept only necessary dependencies
|
||||
|
||||
4. **Separated Concerns**:
|
||||
- Modal management in WysiwygModalManager
|
||||
- Block operations in WysiwygBlockOperations
|
||||
- Input handling in WysiwygInputHandler
|
||||
- Keyboard handling in WysiwygKeyboardHandler
|
||||
- Drag/drop in WysiwygDragDropHandler
|
||||
- Each class has a single responsibility
|
||||
|
||||
5. **Programmatic DOM Management**:
|
||||
- All DOM updates happen through explicit methods
|
||||
- No reactive re-renders during user interaction
|
||||
- Manual class management for drag states
|
||||
- Direct DOM manipulation for performance
|
||||
|
||||
6. **Test Files Created**:
|
||||
- `test-focus-fix.html` - Verifies focus management
|
||||
- `test-drag-drop.html` - Tests drag and drop functionality
|
||||
- `test-comprehensive.html` - Tests all features together
|
||||
|
||||
The refactoring follows the principles in instructions.md:
|
||||
- Uses static templates with manual DOM operations
|
||||
- Maintains separated concerns in different classes
|
||||
- Results in clean, concise, and manageable code
|
||||
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
138
readme.refactoring-summary.md
Normal file
138
readme.refactoring-summary.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# WYSIWYG Editor Refactoring Progress Summary
|
||||
|
||||
## Latest Updates
|
||||
|
||||
### Selection Highlighting Fix ✅
|
||||
- **Issue**: "Paragraphs are not highlighted consistently, headings are always highlighted"
|
||||
- **Root Cause**: The `shouldUpdate` method in `dees-wysiwyg-block.ts` was using a generic `.block` selector that would match the first element with that class, not necessarily the correct block element
|
||||
- **Solution**: Changed the selector to be more specific: `.block.${blockType}` which ensures the correct element is found for each block type
|
||||
- **Result**: All block types now highlight consistently when selected
|
||||
|
||||
### Enter Key Block Creation Fix ✅
|
||||
- **Issue**: "When pressing enter and jumping to new block then typing something: The cursor is not at the beginning of the new block and there is content"
|
||||
- **Root Cause**: Block handlers were rendering content with template syntax `${block.content || ''}` in their render methods, which violates the static HTML principle
|
||||
- **Solution**:
|
||||
- Removed all `${block.content}` from render methods in paragraph, heading, quote, and code block handlers
|
||||
- Content is now set programmatically in the setup() method only when needed
|
||||
- Fixed `setCursorToStart` and `setCursorToEnd` to always find elements fresh instead of relying on cached `blockElement`
|
||||
- **Result**: New empty blocks remain truly empty, cursor positioning works correctly
|
||||
|
||||
### Backspace Key Deletion Fix ✅
|
||||
- **Issue**: "After typing in a new block, pressing backspace deletes the whole block instead of just the last character"
|
||||
- **Root Cause**:
|
||||
1. `getCursorPositionInElement` was using `element.contains()` which doesn't work across Shadow DOM boundaries
|
||||
2. The backspace handler was checking `block.content === ''` which only contains the stored content, not the actual DOM content
|
||||
- **Solution**:
|
||||
1. Fixed `getCursorPositionInElement` to use `containsAcrossShadowDOM` for proper Shadow DOM support
|
||||
2. Updated backspace handler to get actual content from DOM using `blockComponent.getContent()` instead of relying on stored `block.content`
|
||||
3. Added debug logging to track cursor position and content state
|
||||
- **Result**: Backspace now correctly deletes individual characters instead of the whole block
|
||||
|
||||
### Arrow Left Navigation Fix ✅
|
||||
- **Issue**: "When jumping to the previous block from the beginning of a block with arrow left, the cursor should be at the end of the previous block, not at the start"
|
||||
- **Root Cause**: Browser's default focus behavior places cursor at the beginning of contenteditable elements, overriding our cursor positioning
|
||||
- **Solution**: For 'end' position, set up the selection range BEFORE focusing the element:
|
||||
1. Create a range pointing to the end of content
|
||||
2. Apply the selection
|
||||
3. Then focus the element (which preserves the existing selection)
|
||||
4. Only use setCursorToEnd for empty blocks
|
||||
- **Result**: Arrow left navigation now correctly places cursor at the end of the previous block
|
||||
|
||||
## Completed Phases
|
||||
|
||||
### 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.
|
||||
82
readme.refactoring.md
Normal file
82
readme.refactoring.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# WYSIWYG Editor Refactoring
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
This refactoring cleaned up the wysiwyg editor implementation to fix focus, cursor position, and selection issues.
|
||||
|
||||
### Phase 1: Code Organization
|
||||
|
||||
#### 1. Removed Duplicate Code
|
||||
- Removed duplicate `handleBlockInput` method from main component (was already in inputHandler)
|
||||
- Removed duplicate `handleBlockKeyDown` method from main component (was already in keyboardHandler)
|
||||
- Consolidated all input handling in the respective handler classes
|
||||
|
||||
#### 2. Simplified Focus Management
|
||||
- Removed complex `updated` lifecycle method that was trying to maintain focus
|
||||
- Simplified `handleBlockBlur` to not immediately close menus
|
||||
- Added `requestAnimationFrame` to focus operations for better timing
|
||||
- Removed `slashMenuOpenTime` tracking which was no longer needed
|
||||
|
||||
#### 3. Fixed Slash Menu Behavior
|
||||
- Changed from `@mousedown` to `@click` events for better UX
|
||||
- Added proper event prevention to avoid focus loss
|
||||
- Menu now closes when clicking outside
|
||||
- Simplified the insertBlock method to close menu first
|
||||
|
||||
### Phase 2: Cursor & Selection Fixes
|
||||
|
||||
#### 4. Enhanced Cursor Position Management
|
||||
- Added `focusWithCursor()` method to block component for precise cursor positioning
|
||||
- Improved `handleSlashCommand` to preserve cursor position when menu opens
|
||||
- Added `getCaretCoordinates()` for accurate menu positioning based on cursor location
|
||||
- Updated `focusBlock()` to support numeric cursor positions
|
||||
|
||||
#### 5. Fixed Selection Across Shadow DOM
|
||||
- Added custom `block-text-selected` event to communicate selections across shadow boundaries
|
||||
- Implemented `handleMouseUp()` in block component to detect selections
|
||||
- Updated main component to listen for selection events from blocks
|
||||
- Selection now works properly even with nested shadow DOMs
|
||||
|
||||
#### 6. Improved Slash Menu Close Behavior
|
||||
- Added optional `clearSlash` parameter to `closeSlashMenu()`
|
||||
- Escape key now properly clears the slash command
|
||||
- Clicking outside clears the slash if menu is open
|
||||
- Selecting an item preserves content and just transforms the block
|
||||
|
||||
### Technical Improvements
|
||||
|
||||
#### Block Component (`dees-wysiwyg-block`)
|
||||
- Better focus management with immediate focus (removed unnecessary requestAnimationFrame)
|
||||
- Added cursor position control methods
|
||||
- Custom event dispatching for cross-shadow-DOM communication
|
||||
- Improved content handling for different block types
|
||||
|
||||
#### Input Handler
|
||||
- Preserves cursor position when showing slash menu
|
||||
- Better caret coordinate calculation for menu positioning
|
||||
- Ensures focus stays in the block when menu appears
|
||||
|
||||
#### Block Operations
|
||||
- Enhanced `focusBlock()` to support start/end/numeric positions
|
||||
- Better timing with requestAnimationFrame for focus operations
|
||||
|
||||
### Key Benefits
|
||||
- Slash menu no longer causes focus or cursor position loss
|
||||
- Text selection works properly across shadow DOM boundaries
|
||||
- Cursor position is preserved when interacting with menus
|
||||
- Cleaner, more maintainable code structure
|
||||
- Better separation of concerns
|
||||
|
||||
## Testing
|
||||
|
||||
Use the test files in `.nogit/debug/`:
|
||||
- `test-slash-menu.html` - Tests slash menu focus behavior
|
||||
- `test-wysiwyg-formatting.html` - Tests text formatting
|
||||
|
||||
## Known Issues Fixed
|
||||
- Slash menu disappearing immediately on first "/"
|
||||
- Focus lost when slash menu opens
|
||||
- Cursor position lost when typing "/"
|
||||
- Text selection not working properly
|
||||
- Selection events not propagating across shadow DOM
|
||||
- Duplicate event handling causing conflicts
|
||||
@@ -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 () => {
|
||||
const button: deesCatalog.DeesButton = await webhelpers.fixture(
|
||||
@@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
|
||||
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();
|
||||
9
test/test.wysiwyg-basic.browser.ts
Normal file
9
test/test.wysiwyg-basic.browser.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
|
||||
tap.test('should create wysiwyg editor', async () => {
|
||||
const editor = new DeesInputWysiwyg();
|
||||
expect(editor).toBeInstanceOf(DeesInputWysiwyg);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
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();
|
||||
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
156
test/test.wysiwyg-selection-highlight.browser.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Selection highlighting should work consistently for all block types', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import various block types
|
||||
editor.importBlocks([
|
||||
{ id: 'para-1', type: 'paragraph', content: 'This is a paragraph' },
|
||||
{ id: 'heading-1', type: 'heading-1', content: 'This is a heading' },
|
||||
{ id: 'quote-1', type: 'quote', content: 'This is a quote' },
|
||||
{ id: 'code-1', type: 'code', content: 'const x = 42;' },
|
||||
{ id: 'list-1', type: 'list', content: 'Item 1\nItem 2' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Test paragraph highlighting
|
||||
console.log('Testing paragraph highlighting...');
|
||||
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const paraContainer = paraComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const paraElement = paraContainer?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Focus paragraph to select it
|
||||
paraElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if paragraph has selected class
|
||||
const paraHasSelected = paraElement.classList.contains('selected');
|
||||
console.log('Paragraph has selected class:', paraHasSelected);
|
||||
|
||||
// Check computed styles
|
||||
const paraStyle = window.getComputedStyle(paraElement);
|
||||
console.log('Paragraph background:', paraStyle.background);
|
||||
console.log('Paragraph box-shadow:', paraStyle.boxShadow);
|
||||
|
||||
// Test heading highlighting
|
||||
console.log('\nTesting heading highlighting...');
|
||||
const headingWrapper = editor.shadowRoot?.querySelector('[data-block-id="heading-1"]');
|
||||
const headingComponent = headingWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const headingContainer = headingComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const headingElement = headingContainer?.querySelector('.block.heading-1') as HTMLElement;
|
||||
|
||||
// Focus heading to select it
|
||||
headingElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if heading has selected class
|
||||
const headingHasSelected = headingElement.classList.contains('selected');
|
||||
console.log('Heading has selected class:', headingHasSelected);
|
||||
|
||||
// Check computed styles
|
||||
const headingStyle = window.getComputedStyle(headingElement);
|
||||
console.log('Heading background:', headingStyle.background);
|
||||
console.log('Heading box-shadow:', headingStyle.boxShadow);
|
||||
|
||||
// Test quote highlighting
|
||||
console.log('\nTesting quote highlighting...');
|
||||
const quoteWrapper = editor.shadowRoot?.querySelector('[data-block-id="quote-1"]');
|
||||
const quoteComponent = quoteWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const quoteContainer = quoteComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const quoteElement = quoteContainer?.querySelector('.block.quote') as HTMLElement;
|
||||
|
||||
// Focus quote to select it
|
||||
quoteElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if quote has selected class
|
||||
const quoteHasSelected = quoteElement.classList.contains('selected');
|
||||
console.log('Quote has selected class:', quoteHasSelected);
|
||||
|
||||
// Test code highlighting
|
||||
console.log('\nTesting code highlighting...');
|
||||
const codeWrapper = editor.shadowRoot?.querySelector('[data-block-id="code-1"]');
|
||||
const codeComponent = codeWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const codeContainer = codeComponent?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const codeElement = codeContainer?.querySelector('.block.code') as HTMLElement;
|
||||
|
||||
// Focus code to select it
|
||||
codeElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if code has selected class
|
||||
const codeHasSelected = codeElement.classList.contains('selected');
|
||||
console.log('Code has selected class:', codeHasSelected);
|
||||
|
||||
// Focus back on paragraph and check if others are deselected
|
||||
console.log('\nFocusing back on paragraph...');
|
||||
paraElement.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check that only paragraph is selected
|
||||
expect(paraElement.classList.contains('selected')).toBeTrue();
|
||||
expect(headingElement.classList.contains('selected')).toBeFalse();
|
||||
expect(quoteElement.classList.contains('selected')).toBeFalse();
|
||||
expect(codeElement.classList.contains('selected')).toBeFalse();
|
||||
|
||||
console.log('Selection highlighting test complete');
|
||||
});
|
||||
|
||||
tap.test('Selected class should toggle correctly when clicking between blocks', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import two blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'block-1', type: 'paragraph', content: 'First block' },
|
||||
{ id: 'block-2', type: 'paragraph', content: 'Second block' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Get both blocks
|
||||
const block1Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-1"]');
|
||||
const block1Component = block1Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const block1Container = block1Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const block1Element = block1Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
const block2Wrapper = editor.shadowRoot?.querySelector('[data-block-id="block-2"]');
|
||||
const block2Component = block2Wrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const block2Container = block2Component?.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const block2Element = block2Container?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Initially neither should be selected
|
||||
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||
|
||||
// Click on first block
|
||||
block1Element.click();
|
||||
block1Element.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// First block should be selected
|
||||
expect(block1Element.classList.contains('selected')).toBeTrue();
|
||||
expect(block2Element.classList.contains('selected')).toBeFalse();
|
||||
|
||||
// Click on second block
|
||||
block2Element.click();
|
||||
block2Element.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Second block should be selected, first should not
|
||||
expect(block1Element.classList.contains('selected')).toBeFalse();
|
||||
expect(block2Element.classList.contains('selected')).toBeTrue();
|
||||
|
||||
console.log('Toggle test complete');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
62
test/test.wysiwyg-selection-simple.browser.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, tap, webhelpers } from '@git.zone/tstest/tapbundle';
|
||||
import { DeesInputWysiwyg } from '../ts_web/elements/wysiwyg/dees-input-wysiwyg.js';
|
||||
import { DeesWysiwygBlock } from '../ts_web/elements/wysiwyg/dees-wysiwyg-block.js';
|
||||
|
||||
tap.test('Selection highlighting basic test', async () => {
|
||||
const editor: DeesInputWysiwyg = await webhelpers.fixture(
|
||||
webhelpers.html`<dees-input-wysiwyg></dees-input-wysiwyg>`
|
||||
);
|
||||
|
||||
// Import two blocks
|
||||
editor.importBlocks([
|
||||
{ id: 'para-1', type: 'paragraph', content: 'First paragraph' },
|
||||
{ id: 'head-1', type: 'heading-1', content: 'First heading' }
|
||||
]);
|
||||
|
||||
await editor.updateComplete;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get paragraph element
|
||||
const paraWrapper = editor.shadowRoot?.querySelector('[data-block-id="para-1"]');
|
||||
const paraComponent = paraWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const paraBlock = paraComponent?.shadowRoot?.querySelector('.block.paragraph') as HTMLElement;
|
||||
|
||||
// Get heading element
|
||||
const headWrapper = editor.shadowRoot?.querySelector('[data-block-id="head-1"]');
|
||||
const headComponent = headWrapper?.querySelector('dees-wysiwyg-block') as DeesWysiwygBlock;
|
||||
const headBlock = headComponent?.shadowRoot?.querySelector('.block.heading-1') as HTMLElement;
|
||||
|
||||
console.log('Found elements:', {
|
||||
paraBlock: !!paraBlock,
|
||||
headBlock: !!headBlock
|
||||
});
|
||||
|
||||
// Focus paragraph
|
||||
paraBlock.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check classes
|
||||
console.log('Paragraph classes:', paraBlock.className);
|
||||
console.log('Heading classes:', headBlock.className);
|
||||
|
||||
// Check isSelected property
|
||||
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||
|
||||
// Focus heading
|
||||
headBlock.focus();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check classes again
|
||||
console.log('\nAfter focusing heading:');
|
||||
console.log('Paragraph classes:', paraBlock.className);
|
||||
console.log('Heading classes:', headBlock.className);
|
||||
console.log('Paragraph component isSelected:', paraComponent.isSelected);
|
||||
console.log('Heading component isSelected:', headComponent.isSelected);
|
||||
|
||||
// Check that heading is selected
|
||||
expect(headBlock.classList.contains('selected')).toBeTrue();
|
||||
expect(paraBlock.classList.contains('selected')).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '1.8.1',
|
||||
version: '1.9.0',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
h3 {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h3 {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.form-demo {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-demo {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<h3>Button Types</h3>
|
||||
<dees-button>This is a slotted Text</dees-button>
|
||||
<p>
|
||||
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button>
|
||||
@@ -8,8 +44,34 @@ export const demoFunc = () => html`
|
||||
<p><dees-button type="discreet">This is discreete button</dees-button></p>
|
||||
<p><dees-button disabled>This is a disabled button</dees-button></p>
|
||||
<p><dees-button type="big">This is a slotted Text</dees-button></p>
|
||||
|
||||
<h3>Button States</h3>
|
||||
<p><dees-button status="normal">Normal Status</dees-button></p>
|
||||
<p><dees-button disabled status="pending">Pending Status</dees-button></p>
|
||||
<p><dees-button disabled status="success">Success Status</dees-button></p>
|
||||
<p><dees-button disabled status="error">Error Status</dees-button></p>
|
||||
|
||||
<h3>Buttons in Forms (Auto-spacing)</h3>
|
||||
<div class="form-demo">
|
||||
<dees-form>
|
||||
<dees-input-text label="Name" key="name"></dees-input-text>
|
||||
<dees-input-text label="Email" key="email"></dees-input-text>
|
||||
<dees-button>Save Draft</dees-button>
|
||||
<dees-button type="highlighted">Save and Continue</dees-button>
|
||||
<dees-form-submit>Submit Form</dees-form-submit>
|
||||
</dees-form>
|
||||
</div>
|
||||
|
||||
<h3>Buttons Outside Forms (No auto-spacing)</h3>
|
||||
<div class="button-group">
|
||||
<dees-button>Button 1</dees-button>
|
||||
<dees-button>Button 2</dees-button>
|
||||
<dees-button>Button 3</dees-button>
|
||||
</div>
|
||||
|
||||
<h3>Manual Form Spacing</h3>
|
||||
<div>
|
||||
<dees-button inside-form="true">Manually spaced button 1</dees-button>
|
||||
<dees-button inside-form="true">Manually spaced button 2</dees-button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -55,10 +55,24 @@ export class DeesButton extends DeesElement {
|
||||
})
|
||||
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true
|
||||
})
|
||||
public insideForm: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Auto-detect if inside a form
|
||||
if (!this.insideForm && this.closest('dees-form')) {
|
||||
this.insideForm = true;
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
@@ -71,6 +85,27 @@ export class DeesButton extends DeesElement {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form spacing styles */
|
||||
/* Default vertical form layout */
|
||||
:host([inside-form]) {
|
||||
margin-bottom: 16px; /* Using standard 16px like inputs */
|
||||
}
|
||||
|
||||
:host([inside-form]:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Horizontal form layout - auto-detected via parent */
|
||||
dees-form[horizontal-layout] :host([inside-form]) {
|
||||
display: inline-block;
|
||||
margin-right: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dees-form[horizontal-layout] :host([inside-form]:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
transition: all 0.1s , color 0s;
|
||||
position: relative;
|
||||
|
||||
3
ts_web/elements/dees-form-submit.demo.ts
Normal file
3
ts_web/elements/dees-form-submit.demo.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { html } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { demoFunc } from './dees-form-submit.demo.js';
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
cssManager,
|
||||
property,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesForm } from './dees-form.js';
|
||||
import type { DeesForm } from './dees-form.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -16,7 +17,7 @@ declare global {
|
||||
|
||||
@customElement('dees-form-submit')
|
||||
export class DeesFormSubmit extends DeesElement {
|
||||
public static demo = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
@@ -57,13 +58,15 @@ export class DeesFormSubmit extends DeesElement {
|
||||
return;
|
||||
}
|
||||
const parentElement: DeesForm = this.parentElement as DeesForm;
|
||||
if (parentElement && parentElement.gatherAndDispatch) {
|
||||
parentElement.gatherAndDispatch();
|
||||
}
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
if (!this.disabled) {
|
||||
domtools.convenience.smartdelay.delayFor(0);
|
||||
await domtools.convenience.smartdelay.delayFor(0);
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,68 +15,18 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.demo-section {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #0069f2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.demo-section p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.demo-section p {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.form-container {
|
||||
background: #222;
|
||||
border-color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-form {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<h3>Complete Form Example</h3>
|
||||
<p>A comprehensive form with various input types, validation, and form submission handling</p>
|
||||
|
||||
<div class="form-container">
|
||||
<dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
|
||||
<dees-form
|
||||
@formData=${async (eventArg) => {
|
||||
const form: DeesForm = eventArg.currentTarget;
|
||||
@@ -142,14 +92,9 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-form-submit>Create Account</dees-form-submit>
|
||||
</dees-form>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Horizontal Form Layout</h3>
|
||||
<p>Compact form with inputs arranged horizontally - perfect for filters and quick forms</p>
|
||||
|
||||
<div class="form-container">
|
||||
<dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
|
||||
<dees-form horizontal-layout>
|
||||
<dees-input-text
|
||||
key="search"
|
||||
@@ -186,14 +131,9 @@ export const demoFunc = () => html`
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<div class="demo-section">
|
||||
<h3>Advanced Form Features</h3>
|
||||
<p>Form with specialized input types and complex validation</p>
|
||||
|
||||
<div class="form-container">
|
||||
<dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
|
||||
<dees-form
|
||||
@formData=${async (eventArg) => {
|
||||
const form: DeesForm = eventArg.currentTarget;
|
||||
@@ -241,8 +181,7 @@ export const demoFunc = () => html`
|
||||
|
||||
<dees-form-submit>Submit Application</dees-form-submit>
|
||||
</dees-form>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -132,12 +132,31 @@ export class DeesForm extends DeesElement {
|
||||
public async collectFormData() {
|
||||
const children = this.getFormElements();
|
||||
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {};
|
||||
const radioGroups = new Map<string, DeesInputRadio[]>();
|
||||
|
||||
for (const child of children) {
|
||||
if (!child.key) {
|
||||
console.log(`form element with label "${child.label}" has no key. skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle radio buttons specially
|
||||
if (child instanceof DeesInputRadio && child.name) {
|
||||
if (!radioGroups.has(child.name)) {
|
||||
radioGroups.set(child.name, []);
|
||||
}
|
||||
radioGroups.get(child.name).push(child);
|
||||
} else {
|
||||
valueObject[child.key] = child.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Process radio groups - use the name as key and selected radio's key as value
|
||||
for (const [groupName, radios] of radioGroups) {
|
||||
const selectedRadio = radios.find(radio => radio.value === true);
|
||||
valueObject[groupName] = selectedRadio ? selectedRadio.key : null;
|
||||
}
|
||||
|
||||
return valueObject;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,33 +3,7 @@ import '@design.estate/dees-wcctools/demotools';
|
||||
import type { DeesInputRadio } from './dees-input-radio.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Implement radio group behavior
|
||||
const radioGroups = new Map<string, DeesInputRadio[]>();
|
||||
|
||||
// Group radios by their container
|
||||
const radioContainers = elementArg.querySelectorAll('.radio-group');
|
||||
radioContainers.forEach((container) => {
|
||||
const radios = Array.from(container.querySelectorAll('dees-input-radio')) as DeesInputRadio[];
|
||||
const groupName = container.getAttribute('data-group') || 'default';
|
||||
radioGroups.set(groupName, radios);
|
||||
|
||||
// Add click handlers for radio group behavior
|
||||
radios.forEach((radio) => {
|
||||
radio.addEventListener('click', () => {
|
||||
if (!radio.disabled && !radio.value) {
|
||||
// Uncheck all other radios in the group
|
||||
radios.forEach((r) => {
|
||||
if (r !== radio) {
|
||||
r.value = false;
|
||||
}
|
||||
});
|
||||
radio.value = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
@@ -121,37 +95,43 @@ export const demoFunc = () => html`
|
||||
<h3>Basic Radio Groups</h3>
|
||||
<p>Radio buttons for single-choice selections</p>
|
||||
|
||||
<div class="radio-group" data-group="plan">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">Select your subscription plan:</div>
|
||||
<dees-input-radio
|
||||
.label=${'Basic Plan - $9/month'}
|
||||
.value=${true}
|
||||
.key=${'plan-basic'}
|
||||
.name=${'plan'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Pro Plan - $29/month'}
|
||||
.key=${'plan-pro'}
|
||||
.name=${'plan'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Enterprise Plan - $99/month'}
|
||||
.key=${'plan-enterprise'}
|
||||
.name=${'plan'}
|
||||
></dees-input-radio>
|
||||
</div>
|
||||
|
||||
<div class="radio-group" data-group="priority">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">Task Priority:</div>
|
||||
<dees-input-radio
|
||||
.label=${'High Priority'}
|
||||
.key=${'priority-high'}
|
||||
.name=${'priority'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Medium Priority'}
|
||||
.value=${true}
|
||||
.key=${'priority-medium'}
|
||||
.name=${'priority'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Low Priority'}
|
||||
.key=${'priority-low'}
|
||||
.name=${'priority'}
|
||||
></dees-input-radio>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,43 +140,49 @@ export const demoFunc = () => html`
|
||||
<h3>Horizontal Layout</h3>
|
||||
<p>Radio buttons arranged horizontally for yes/no questions</p>
|
||||
|
||||
<div class="radio-group" data-group="agreement" style="flex-direction: row;">
|
||||
<div class="radio-group" style="flex-direction: row;">
|
||||
<div style="margin-right: 16px;">Do you agree?</div>
|
||||
<dees-input-radio
|
||||
.label=${'Yes'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${true}
|
||||
.key=${'agree-yes'}
|
||||
.name=${'agreement'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'No'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'agree-no'}
|
||||
.name=${'agreement'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Maybe'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'agree-maybe'}
|
||||
.name=${'agreement'}
|
||||
></dees-input-radio>
|
||||
</div>
|
||||
|
||||
<div class="radio-group" data-group="experience" style="flex-direction: row;">
|
||||
<div class="radio-group" style="flex-direction: row;">
|
||||
<div style="margin-right: 16px;">Experience Level:</div>
|
||||
<dees-input-radio
|
||||
.label=${'Beginner'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'exp-beginner'}
|
||||
.name=${'experience'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Intermediate'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${true}
|
||||
.key=${'exp-intermediate'}
|
||||
.name=${'experience'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Expert'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'exp-expert'}
|
||||
.name=${'experience'}
|
||||
></dees-input-radio>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,22 +192,22 @@ export const demoFunc = () => html`
|
||||
<p>Multiple radio groups in a survey format</p>
|
||||
|
||||
<div class="grid-layout">
|
||||
<div class="radio-group" data-group="satisfaction">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">How satisfied are you?</div>
|
||||
<dees-input-radio .label=${'Very Satisfied'} .key=${'sat-very'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Satisfied'} .value=${true} .key=${'sat-normal'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Neutral'} .key=${'sat-neutral'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Dissatisfied'} .key=${'sat-dis'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Very Dissatisfied'} .key=${'sat-verydis'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Very Satisfied'} .key=${'sat-very'} .name=${'satisfaction'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Satisfied'} .value=${true} .key=${'sat-normal'} .name=${'satisfaction'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Neutral'} .key=${'sat-neutral'} .name=${'satisfaction'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Dissatisfied'} .key=${'sat-dis'} .name=${'satisfaction'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Very Dissatisfied'} .key=${'sat-verydis'} .name=${'satisfaction'}></dees-input-radio>
|
||||
</div>
|
||||
|
||||
<div class="radio-group" data-group="recommend">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">Would you recommend us?</div>
|
||||
<dees-input-radio .label=${'Definitely'} .key=${'rec-def'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Probably'} .value=${true} .key=${'rec-prob'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Not Sure'} .key=${'rec-unsure'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Probably Not'} .key=${'rec-probnot'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Definitely Not'} .key=${'rec-defnot'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Definitely'} .key=${'rec-def'} .name=${'recommend'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Probably'} .value=${true} .key=${'rec-prob'} .name=${'recommend'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Not Sure'} .key=${'rec-unsure'} .name=${'recommend'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Probably Not'} .key=${'rec-probnot'} .name=${'recommend'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Definitely Not'} .key=${'rec-defnot'} .name=${'recommend'}></dees-input-radio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,26 +216,30 @@ export const demoFunc = () => html`
|
||||
<h3>States</h3>
|
||||
<p>Different radio button states</p>
|
||||
|
||||
<div class="radio-group" data-group="states">
|
||||
<div class="radio-group">
|
||||
<dees-input-radio
|
||||
.label=${'Normal Radio'}
|
||||
.key=${'state-normal'}
|
||||
.name=${'states'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Selected Radio'}
|
||||
.value=${true}
|
||||
.key=${'state-selected'}
|
||||
.name=${'states'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Disabled Unchecked'}
|
||||
.disabled=${true}
|
||||
.key=${'state-disabled1'}
|
||||
.name=${'states2'}
|
||||
></dees-input-radio>
|
||||
<dees-input-radio
|
||||
.label=${'Disabled Checked'}
|
||||
.disabled=${true}
|
||||
.value=${true}
|
||||
.key=${'state-disabled2'}
|
||||
.name=${'states2'}
|
||||
></dees-input-radio>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,18 +248,18 @@ export const demoFunc = () => html`
|
||||
<h3>Settings Example</h3>
|
||||
<p>Common radio button patterns in settings</p>
|
||||
|
||||
<div class="radio-group" data-group="theme">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">Theme Preference:</div>
|
||||
<dees-input-radio .label=${'Light Theme'} .key=${'theme-light'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Dark Theme'} .value=${true} .key=${'theme-dark'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'System Default'} .key=${'theme-system'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Light Theme'} .key=${'theme-light'} .name=${'theme'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Dark Theme'} .value=${true} .key=${'theme-dark'} .name=${'theme'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'System Default'} .key=${'theme-system'} .name=${'theme'}></dees-input-radio>
|
||||
</div>
|
||||
|
||||
<div class="radio-group" data-group="notifications">
|
||||
<div class="radio-group">
|
||||
<div class="radio-group-title">Notification Frequency:</div>
|
||||
<dees-input-radio .label=${'All Notifications'} .key=${'notif-all'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Important Only'} .value=${true} .key=${'notif-important'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'None'} .key=${'notif-none'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'All Notifications'} .key=${'notif-all'} .name=${'notifications'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'Important Only'} .value=${true} .key=${'notif-important'} .name=${'notifications'}></dees-input-radio>
|
||||
<dees-input-radio .label=${'None'} .key=${'notif-none'} .name=${'notifications'}></dees-input-radio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
|
||||
@property()
|
||||
public value: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public name: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -95,7 +97,27 @@ export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
|
||||
}
|
||||
|
||||
public async toggleSelected () {
|
||||
this.value = !this.value;
|
||||
// Radio buttons can only be selected, not deselected by clicking
|
||||
if (this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this radio has a name, find and deselect other radios in the same group
|
||||
if (this.name) {
|
||||
// Try to find a form container first, then fall back to document
|
||||
const container = this.closest('dees-form') ||
|
||||
this.closest('dees-demowrapper') ||
|
||||
this.closest('.radio-group')?.parentElement ||
|
||||
document;
|
||||
const allRadios = container.querySelectorAll(`dees-input-radio[name="${this.name}"]`);
|
||||
allRadios.forEach((radio: DeesInputRadio) => {
|
||||
if (radio !== this && radio.value) {
|
||||
radio.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.value = true;
|
||||
this.dispatchEvent(new CustomEvent('newValue', {
|
||||
detail: this.value,
|
||||
bubbles: true
|
||||
|
||||
@@ -100,7 +100,8 @@ export class DeesInputText extends DeesInputBase {
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme( colors.bright.blueActive, colors.dark.blueActive)};
|
||||
border-bottom: 1px solid
|
||||
${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
@@ -117,6 +118,7 @@ export class DeesInputText extends DeesInputBase {
|
||||
padding: 4px 0px;
|
||||
width: 40px;
|
||||
z-index: 3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.showPassword:hover {
|
||||
@@ -146,12 +148,14 @@ export class DeesInputText extends DeesInputBase {
|
||||
letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'};
|
||||
color: ${this.goBright ? '#333' : '#ccc'};
|
||||
}
|
||||
${this.validationText ? css`
|
||||
${this.validationText
|
||||
? css`
|
||||
.validationContainer {
|
||||
height: 22px;
|
||||
opacity: 1;
|
||||
}
|
||||
` : css`
|
||||
`
|
||||
: css`
|
||||
.validationContainer {
|
||||
height: 4px;
|
||||
padding: 2px !important;
|
||||
@@ -168,9 +172,7 @@ export class DeesInputText extends DeesInputBase {
|
||||
@input="${this.updateValue}"
|
||||
.disabled=${this.disabled}
|
||||
/>
|
||||
<div class="validationContainer">
|
||||
${this.validationText}
|
||||
</div>
|
||||
<div class="validationContainer">${this.validationText}</div>
|
||||
${this.isPasswordBool
|
||||
? html`
|
||||
<div class="showPassword" @click=${this.togglePasswordView}>
|
||||
|
||||
1068
ts_web/elements/dees-input-wysiwyg.demo.ts
Normal file
1068
ts_web/elements/dees-input-wysiwyg.demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
ts_web/elements/dees-input-wysiwyg.ts
Normal file
2
ts_web/elements/dees-input-wysiwyg.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export the modular component from the wysiwyg directory
|
||||
export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js';
|
||||
@@ -93,12 +93,12 @@ export class DeesModal extends DeesElement {
|
||||
opacity: 0;
|
||||
width: 480px;
|
||||
min-height: 120px;
|
||||
background: #111;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111')};
|
||||
border-radius: 8px;
|
||||
border: 1px solid #222;
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 2px 5px #00000080;
|
||||
box-shadow: ${cssManager.bdTheme('0px 2px 10px rgba(0, 0, 0, 0.1)', '0px 2px 5px rgba(0, 0, 0, 0.5)')};
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
@@ -118,7 +118,7 @@ export class DeesModal extends DeesElement {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #222;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
}
|
||||
|
||||
.modal .content {
|
||||
@@ -127,7 +127,7 @@ export class DeesModal extends DeesElement {
|
||||
.modal .bottomButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-top: 1px solid #222;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -138,8 +138,10 @@ export class DeesModal extends DeesElement {
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
}
|
||||
|
||||
.modal .bottomButtons .bottomButton:first-child {
|
||||
@@ -151,13 +153,26 @@ export class DeesModal extends DeesElement {
|
||||
|
||||
.modal .bottomButtons .bottomButton:hover {
|
||||
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
||||
color: #ffffff;
|
||||
}
|
||||
.modal .bottomButtons .bottomButton:active {
|
||||
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
||||
color: #ffffff;
|
||||
}
|
||||
.modal .bottomButtons .bottomButton:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.modal .bottomButtons .bottomButton.primary {
|
||||
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
|
||||
color: #ffffff;
|
||||
}
|
||||
.modal .bottomButtons .bottomButton.primary:hover {
|
||||
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
|
||||
}
|
||||
.modal .bottomButtons .bottomButton.primary:active {
|
||||
background: ${cssManager.bdTheme(colors.bright.blueMuted, colors.dark.blueMuted)};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -174,8 +189,8 @@ export class DeesModal extends DeesElement {
|
||||
<div class="content">${this.content}</div>
|
||||
<div class="bottomButtons">
|
||||
${this.menuOptions.map(
|
||||
(actionArg) => html`
|
||||
<div class="bottomButton" @click=${() => {
|
||||
(actionArg, index) => html`
|
||||
<div class="bottomButton ${index === this.menuOptions.length - 1 ? 'primary' : ''} ${actionArg.name === 'OK' ? 'ok' : ''}" @click=${() => {
|
||||
actionArg.action(this);
|
||||
}}>${actionArg.name}</div>
|
||||
`
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from './dees-input-fileupload.js';
|
||||
export * from './dees-input-iban.js';
|
||||
export * from './dees-input-typelist.js';
|
||||
export * from './dees-input-phone.js';
|
||||
export * from './dees-input-wysiwyg.js';
|
||||
export * from './dees-progressbar.js';
|
||||
export * from './dees-input-quantityselector.js';
|
||||
export * from './dees-input-radio.js';
|
||||
|
||||
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"
|
||||
></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)
|
||||
};
|
||||
}
|
||||
}
|
||||
610
ts_web/elements/wysiwyg/blocks/text/heading.block.ts
Normal file
610
ts_web/elements/wysiwyg/blocks/text/heading.block.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
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}"
|
||||
></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 to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper methods for heading functionality (mostly the same as paragraph)
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
|
||||
// Set up the selection first
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
const range = document.createRange();
|
||||
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(lastNode);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
// Now focus the element
|
||||
headingBlock.focus();
|
||||
|
||||
// Set cursor position after focus is established (for non-end positions)
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
|
||||
// Only call setCursorToEnd for empty blocks
|
||||
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();
|
||||
} else {
|
||||
// Try again with a small delay - sometimes focus needs more time
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||
setCursor();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
582
ts_web/elements/wysiwyg/blocks/text/paragraph.block.ts
Normal file
582
ts_web/elements/wysiwyg/blocks/text/paragraph.block.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
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}"
|
||||
></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 to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper methods for paragraph functionality
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
|
||||
// Set up the selection first
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
const range = document.createRange();
|
||||
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(lastNode);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
// Now focus the element
|
||||
paragraphBlock.focus();
|
||||
|
||||
// Set cursor position after focus is established (for non-end positions)
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
|
||||
// Only call setCursorToEnd for empty blocks
|
||||
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();
|
||||
} else {
|
||||
// Try again with a small delay - sometimes focus needs more time
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||
setCursor();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
></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
|
||||
};
|
||||
}
|
||||
}
|
||||
203
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal file
203
ts_web/elements/wysiwyg/dees-formatting-menu.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { WysiwygFormatting } from './wysiwyg.formatting.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-formatting-menu': DeesFormattingMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-formatting-menu')
|
||||
export class DeesFormattingMenu extends DeesElement {
|
||||
private static instance: DeesFormattingMenu;
|
||||
|
||||
public static getInstance(): DeesFormattingMenu {
|
||||
if (!DeesFormattingMenu.instance) {
|
||||
DeesFormattingMenu.instance = new DeesFormattingMenu();
|
||||
document.body.appendChild(DeesFormattingMenu.instance);
|
||||
}
|
||||
return DeesFormattingMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
public visible: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
private callback: ((command: string) => void | Promise<void>) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.formatting-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.format-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.format-button.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-button.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-button.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-button .code-icon {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="formatting-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
data-menu-type="formatting"
|
||||
>
|
||||
${WysiwygFormatting.formatButtons.map(button => html`
|
||||
<button
|
||||
class="format-button ${button.command}"
|
||||
data-command="${button.command}"
|
||||
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||
>
|
||||
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private applyFormat(command: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(command);
|
||||
}
|
||||
// Don't hide menu after applying format (except for link)
|
||||
if (command === 'link') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
|
||||
console.log('FormattingMenu.show called:', { position, visible: this.visible });
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
this.visible = true;
|
||||
console.log('FormattingMenu.show - visible set to:', this.visible);
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
}
|
||||
|
||||
public updatePosition(position: { x: number; y: number }): void {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
// Set up event delegation for the menu
|
||||
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const button = target.closest('.format-button') as HTMLElement;
|
||||
|
||||
if (button) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const command = button.getAttribute('data-command');
|
||||
if (command) {
|
||||
this.applyFormat(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
}
|
||||
}
|
||||
1014
ts_web/elements/wysiwyg/dees-input-wysiwyg.ts
Normal file
1014
ts_web/elements/wysiwyg/dees-input-wysiwyg.ts
Normal file
File diff suppressed because it is too large
Load Diff
242
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal file
242
ts_web/elements/wysiwyg/dees-slash-menu.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-slash-menu': DeesSlashMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-slash-menu')
|
||||
export class DeesSlashMenu extends DeesElement {
|
||||
private static instance: DeesSlashMenu;
|
||||
|
||||
public static getInstance(): DeesSlashMenu {
|
||||
if (!DeesSlashMenu.instance) {
|
||||
DeesSlashMenu.instance = new DeesSlashMenu();
|
||||
document.body.appendChild(DeesSlashMenu.instance);
|
||||
}
|
||||
return DeesSlashMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
public visible: boolean = false;
|
||||
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
@state()
|
||||
private filter: string = '';
|
||||
|
||||
@state()
|
||||
private selectedIndex: number = 0;
|
||||
|
||||
private callback: ((type: string) => void) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
const menuItems = this.getFilteredMenuItems();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="slash-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
data-menu-type="slash"
|
||||
>
|
||||
${menuItems.map((item, index) => html`
|
||||
<div
|
||||
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
|
||||
data-item-type="${item.type}"
|
||||
data-item-index="${index}"
|
||||
>
|
||||
<span class="icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
||||
return allItems.filter(item =>
|
||||
this.filter === '' ||
|
||||
item.label.toLowerCase().includes(this.filter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
private selectItem(type: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(type);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
public updateFilter(filter: string): void {
|
||||
this.filter = filter;
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
public navigate(direction: 'up' | 'down'): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (direction === 'down') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % items.length;
|
||||
} else {
|
||||
this.selectedIndex = this.selectedIndex === 0
|
||||
? items.length - 1
|
||||
: this.selectedIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
public selectCurrent(): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (items[this.selectedIndex]) {
|
||||
this.selectItem(items[this.selectedIndex].type);
|
||||
}
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
// Set up event delegation
|
||||
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.slash-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const itemType = menuItem.getAttribute('data-item-type');
|
||||
if (itemType) {
|
||||
this.selectItem(itemType);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem) {
|
||||
const index = parseInt(menuItem.getAttribute('data-item-index') || '0', 10);
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
|
||||
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.slash-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
}
|
||||
}
|
||||
2192
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
2192
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
ts_web/elements/wysiwyg/index.ts
Normal file
18
ts_web/elements/wysiwyg/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export * from './wysiwyg.types.js';
|
||||
export * from './wysiwyg.interfaces.js';
|
||||
export * from './wysiwyg.constants.js';
|
||||
export * from './wysiwyg.styles.js';
|
||||
export * from './wysiwyg.converters.js';
|
||||
export * from './wysiwyg.shortcuts.js';
|
||||
export * from './wysiwyg.blocks.js';
|
||||
export * from './wysiwyg.formatting.js';
|
||||
export * from './wysiwyg.selection.js';
|
||||
export * from './wysiwyg.blockoperations.js';
|
||||
export * from './wysiwyg.inputhandler.js';
|
||||
export * from './wysiwyg.keyboardhandler.js';
|
||||
export * from './wysiwyg.dragdrophandler.js';
|
||||
export * from './wysiwyg.modalmanager.js';
|
||||
export * from './wysiwyg.history.js';
|
||||
export * from './dees-wysiwyg-block.js';
|
||||
export * from './dees-slash-menu.js';
|
||||
export * from './dees-formatting-menu.js';
|
||||
7
ts_web/elements/wysiwyg/instructions.md
Normal file
7
ts_web/elements/wysiwyg/instructions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
|
||||
* We try to have separated concerns in different classes
|
||||
* We try to have clean concise and managable code
|
||||
* lets log whats happening, so if something goes wrong, we understand whats happening.
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Make sure to hand over correct shodowroots.
|
||||
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.
|
||||
177
ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts
Normal file
177
ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
|
||||
export class WysiwygBlockOperations {
|
||||
private component: IWysiwygComponent;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new block with the specified parameters
|
||||
*/
|
||||
createBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
|
||||
return {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type,
|
||||
content,
|
||||
...(metadata && { metadata })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a block after the specified block
|
||||
*/
|
||||
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
|
||||
const blocks = this.component.blocks;
|
||||
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
||||
|
||||
this.component.blocks = [
|
||||
...blocks.slice(0, blockIndex + 1),
|
||||
newBlock,
|
||||
...blocks.slice(blockIndex + 1)
|
||||
];
|
||||
|
||||
// Insert the new block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
const afterWrapper = this.component.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
|
||||
if (afterWrapper) {
|
||||
const newWrapper = this.component.createBlockElement(newBlock);
|
||||
afterWrapper.insertAdjacentElement('afterend', newWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Focus the new block
|
||||
await this.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a block by its ID
|
||||
*/
|
||||
removeBlock(blockId: string): void {
|
||||
// Save checkpoint before deletion
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
||||
|
||||
// Remove the block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
const wrapper = this.component.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.remove();
|
||||
}
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a block by its ID
|
||||
*/
|
||||
findBlock(blockId: string): IBlock | undefined {
|
||||
return this.component.blocks.find((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the index of a block
|
||||
*/
|
||||
getBlockIndex(blockId: string): number {
|
||||
return this.component.blocks.findIndex((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a specific block
|
||||
*/
|
||||
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
|
||||
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (wrapperElement) {
|
||||
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
// Wait a frame to ensure the block is rendered
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Now focus with cursor position
|
||||
blockComponent.focusWithCursor(cursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content of a block
|
||||
*/
|
||||
updateBlockContent(blockId: string, content: string): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
this.component.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a block to a different type
|
||||
*/
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
// Save checkpoint before transformation
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
block.type = newType;
|
||||
block.content = '';
|
||||
if (metadata) {
|
||||
block.metadata = metadata;
|
||||
}
|
||||
|
||||
// Update the block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(blockId);
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block to a new position
|
||||
*/
|
||||
moveBlock(blockId: string, targetIndex: number): void {
|
||||
const blocks = [...this.component.blocks];
|
||||
const currentIndex = this.getBlockIndex(blockId);
|
||||
|
||||
if (currentIndex === -1 || targetIndex < 0 || targetIndex >= blocks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedBlock] = blocks.splice(currentIndex, 1);
|
||||
blocks.splice(targetIndex, 0, movedBlock);
|
||||
|
||||
this.component.blocks = blocks;
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the previous block
|
||||
*/
|
||||
getPreviousBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index > 0 ? this.component.blocks[index - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next block
|
||||
*/
|
||||
getNextBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index < this.component.blocks.length - 1 ? this.component.blocks[index + 1] : null;
|
||||
}
|
||||
}
|
||||
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();
|
||||
202
ts_web/elements/wysiwyg/wysiwyg.blocks.ts
Normal file
202
ts_web/elements/wysiwyg/wysiwyg.blocks.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygConverters } from './wysiwyg.converters.js';
|
||||
|
||||
export class WysiwygBlocks {
|
||||
static renderListContent(content: string, metadata?: any): string {
|
||||
const items = content.split('\n').filter(item => item.trim());
|
||||
if (items.length === 0) return '';
|
||||
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
// Don't escape HTML to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
|
||||
static renderBlock(
|
||||
block: IBlock,
|
||||
isSelected: boolean,
|
||||
handlers: {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
}
|
||||
): TemplateResult {
|
||||
if (block.type === 'divider') {
|
||||
return html`
|
||||
<div
|
||||
class="block divider"
|
||||
data-block-id="${block.id}"
|
||||
>
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return html`
|
||||
<div
|
||||
class="block list ${isSelected ? 'selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special rendering for code blocks with language indicator
|
||||
if (block.type === 'code') {
|
||||
const language = block.metadata?.language || 'plain text';
|
||||
return html`
|
||||
<div class="code-block-container">
|
||||
<div class="code-language">${language}</div>
|
||||
<div
|
||||
class="block ${block.type} ${isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.textContent="${block.content || ''}"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const blockElement = html`
|
||||
<div
|
||||
class="block ${block.type} ${isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.innerHTML="${block.content || ''}"
|
||||
></div>
|
||||
`;
|
||||
|
||||
return blockElement;
|
||||
}
|
||||
|
||||
static setCursorToEnd(element: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element - add a zero-width space to enable cursor
|
||||
const textNode = document.createTextNode('\u200B');
|
||||
element.appendChild(textNode);
|
||||
range.setStart(textNode, 1);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the last text node or element
|
||||
const lastNode = this.getLastNode(element);
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.setStartAfter(lastNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
// Remove zero-width space if it was added
|
||||
if (element.textContent === '\u200B') {
|
||||
element.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
static setCursorToStart(element: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element
|
||||
range.setStart(element, 0);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the first text node or element
|
||||
const firstNode = this.getFirstNode(element);
|
||||
if (firstNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(firstNode, 0);
|
||||
} else {
|
||||
range.setStartBefore(firstNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
private static getLastNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const lastChild = element.childNodes[element.childNodes.length - 1];
|
||||
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
|
||||
return lastChild;
|
||||
}
|
||||
|
||||
return this.getLastNode(lastChild);
|
||||
}
|
||||
|
||||
private static getFirstNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const firstChild = element.childNodes[0];
|
||||
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
|
||||
return firstChild;
|
||||
}
|
||||
|
||||
return this.getFirstNode(firstChild);
|
||||
}
|
||||
|
||||
static focusListItem(listElement: HTMLElement): void {
|
||||
const firstLi = listElement.querySelector('li');
|
||||
if (firstLi) {
|
||||
firstLi.focus();
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(firstLi);
|
||||
range.collapse(true);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal file
27
ts_web/elements/wysiwyg/wysiwyg.constants.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared constants for the WYSIWYG editor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available programming languages for code blocks
|
||||
*/
|
||||
export const PROGRAMMING_LANGUAGES = [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Python',
|
||||
'Java',
|
||||
'C++',
|
||||
'C#',
|
||||
'Go',
|
||||
'Rust',
|
||||
'HTML',
|
||||
'CSS',
|
||||
'SQL',
|
||||
'Shell',
|
||||
'JSON',
|
||||
'YAML',
|
||||
'Markdown',
|
||||
'Plain Text'
|
||||
] as const;
|
||||
|
||||
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];
|
||||
329
ts_web/elements/wysiwyg/wysiwyg.converters.ts
Normal file
329
ts_web/elements/wysiwyg/wysiwyg.converters.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygConverters {
|
||||
static escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
static formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
static getHtmlOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
// Check if content already contains HTML formatting
|
||||
const content = block.content.includes('<') && block.content.includes('>')
|
||||
? block.content // Already contains HTML formatting
|
||||
: this.escapeHtml(block.content);
|
||||
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content ? `<p>${content}</p>` : '';
|
||||
case 'heading-1':
|
||||
return `<h1>${content}</h1>`;
|
||||
case 'heading-2':
|
||||
return `<h2>${content}</h2>`;
|
||||
case 'heading-3':
|
||||
return `<h3>${content}</h3>`;
|
||||
case 'quote':
|
||||
return `<blockquote>${content}</blockquote>`;
|
||||
case 'code':
|
||||
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (items.length > 0) {
|
||||
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
// Don't escape HTML in list items to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
return '';
|
||||
case 'divider':
|
||||
return '<hr>';
|
||||
case 'image':
|
||||
const imageUrl = block.metadata?.url;
|
||||
if (imageUrl) {
|
||||
const altText = this.escapeHtml(block.content || 'Image');
|
||||
return `<img src="${imageUrl}" alt="${altText}" />`;
|
||||
}
|
||||
return '';
|
||||
case 'youtube':
|
||||
const videoId = block.metadata?.videoId;
|
||||
if (videoId) {
|
||||
return `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
|
||||
}
|
||||
return '';
|
||||
case 'markdown':
|
||||
// Return the raw markdown content wrapped in a div
|
||||
return `<div class="markdown-content">${this.escapeHtml(block.content)}</div>`;
|
||||
case 'html':
|
||||
// Return the raw HTML content (already HTML)
|
||||
return block.content;
|
||||
case 'attachment':
|
||||
const files = block.metadata?.files || [];
|
||||
if (files.length > 0) {
|
||||
return `<div class="attachments">${files.map((file: any) =>
|
||||
`<div class="attachment-item" data-file-id="${file.id}">
|
||||
<a href="${file.data}" download="${file.name}">${this.escapeHtml(file.name)}</a>
|
||||
<span class="file-size">(${this.formatFileSize(file.size)})</span>
|
||||
</div>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
return '';
|
||||
default:
|
||||
return `<p>${content}</p>`;
|
||||
}
|
||||
}).filter(html => html !== '').join('\n');
|
||||
}
|
||||
|
||||
static getMarkdownOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content;
|
||||
case 'heading-1':
|
||||
return `# ${block.content}`;
|
||||
case 'heading-2':
|
||||
return `## ${block.content}`;
|
||||
case 'heading-3':
|
||||
return `### ${block.content}`;
|
||||
case 'quote':
|
||||
return `> ${block.content}`;
|
||||
case 'code':
|
||||
return `\`\`\`\n${block.content}\n\`\`\``;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (block.metadata?.listType === 'ordered') {
|
||||
return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
|
||||
} else {
|
||||
return items.map(item => `- ${item}`).join('\n');
|
||||
}
|
||||
case 'divider':
|
||||
return '---';
|
||||
case 'image':
|
||||
const imageUrl = block.metadata?.url;
|
||||
const altText = block.content || 'Image';
|
||||
return imageUrl ? `` : '';
|
||||
case 'youtube':
|
||||
const videoId = block.metadata?.videoId;
|
||||
const url = block.metadata?.url || (videoId ? `https://youtube.com/watch?v=${videoId}` : '');
|
||||
return url ? `[YouTube Video](${url})` : '';
|
||||
case 'markdown':
|
||||
// Return the raw markdown content
|
||||
return block.content;
|
||||
case 'html':
|
||||
// Return as HTML comment in markdown
|
||||
return `<!-- HTML Block\n${block.content}\n-->`;
|
||||
case 'attachment':
|
||||
const files = block.metadata?.files || [];
|
||||
if (files.length > 0) {
|
||||
return files.map((file: any) => `- [${file.name}](${file.data})`).join('\n');
|
||||
}
|
||||
return '';
|
||||
default:
|
||||
return block.content;
|
||||
}
|
||||
}).filter(md => md !== '').join('\n\n');
|
||||
}
|
||||
|
||||
static parseHtmlToBlocks(html: string): IBlock[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const blocks: IBlock[] = [];
|
||||
|
||||
const processNode = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: node.textContent.trim(),
|
||||
});
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
switch (tagName) {
|
||||
case 'p':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h1':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h2':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h3':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'blockquote':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'pre':
|
||||
case 'code':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
const listItems = Array.from(element.querySelectorAll('li'));
|
||||
// Use innerHTML to preserve formatting
|
||||
const content = listItems.map(li => li.innerHTML || '').join('\n');
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: content,
|
||||
metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' }
|
||||
});
|
||||
break;
|
||||
case 'hr':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
break;
|
||||
case 'img':
|
||||
const imgElement = element as HTMLImageElement;
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'image',
|
||||
content: imgElement.alt || '',
|
||||
metadata: { url: imgElement.src }
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Process children for other elements
|
||||
element.childNodes.forEach(child => processNode(child));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
doc.body.childNodes.forEach(node => processNode(node));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
static parseMarkdownToBlocks(markdown: string): IBlock[] {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: IBlock[] = [];
|
||||
let currentListItems: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('## ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: line.substring(3),
|
||||
});
|
||||
} else if (line.startsWith('### ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: line.substring(4),
|
||||
});
|
||||
} else if (line.startsWith('> ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('```')) {
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: codeLines.join('\n'),
|
||||
});
|
||||
} else if (line.match(/^(\*|-) /)) {
|
||||
currentListItems.push(line.substring(2));
|
||||
// Check if next line is not a list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'bullet' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line.match(/^\d+\. /)) {
|
||||
currentListItems.push(line.replace(/^\d+\. /, ''));
|
||||
// Check if next line is not a numbered list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'ordered' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line === '---' || line === '***' || line === '___') {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
} else if (line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/)) {
|
||||
// Parse markdown image syntax 
|
||||
const match = line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
|
||||
if (match) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'image',
|
||||
content: match[1] || '',
|
||||
metadata: { url: match[2] }
|
||||
});
|
||||
}
|
||||
} else if (line.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
405
ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts
Normal file
405
ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
|
||||
export class WysiwygDragDropHandler {
|
||||
private component: IWysiwygComponent;
|
||||
private draggedBlockId: string | null = null;
|
||||
private dragOverBlockId: string | null = null;
|
||||
private dragOverPosition: 'before' | 'after' | null = null;
|
||||
private dropIndicator: HTMLElement | null = null;
|
||||
private initialMouseY: number = 0;
|
||||
private initialBlockY: number = 0;
|
||||
private draggedBlockElement: HTMLElement | null = null;
|
||||
private draggedBlockHeight: number = 0;
|
||||
private lastUpdateTime: number = 0;
|
||||
private updateThrottle: number = 80; // milliseconds
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current drag state
|
||||
*/
|
||||
get dragState() {
|
||||
return {
|
||||
draggedBlockId: this.draggedBlockId,
|
||||
dragOverBlockId: this.dragOverBlockId,
|
||||
dragOverPosition: this.dragOverPosition
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag start
|
||||
*/
|
||||
handleDragStart(e: DragEvent, block: IBlock): void {
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
this.draggedBlockId = block.id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', block.id);
|
||||
|
||||
// Hide the default drag image
|
||||
const emptyImg = new Image();
|
||||
emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
// Store initial mouse position and block element
|
||||
this.initialMouseY = e.clientY;
|
||||
this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
||||
|
||||
if (this.draggedBlockElement) {
|
||||
const rect = this.draggedBlockElement.getBoundingClientRect();
|
||||
this.draggedBlockHeight = rect.height;
|
||||
this.initialBlockY = rect.top;
|
||||
|
||||
// Create drop indicator
|
||||
this.createDropIndicator();
|
||||
|
||||
// Set up drag event listeners
|
||||
document.addEventListener('dragover', this.handleGlobalDragOver);
|
||||
document.addEventListener('dragend', this.handleGlobalDragEnd);
|
||||
}
|
||||
|
||||
// Update component state
|
||||
this.component.draggedBlockId = this.draggedBlockId;
|
||||
|
||||
// Add dragging class after a small delay
|
||||
setTimeout(() => {
|
||||
if (this.draggedBlockElement) {
|
||||
this.draggedBlockElement.classList.add('dragging');
|
||||
}
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.editorContentRef.classList.add('dragging');
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag end
|
||||
*/
|
||||
handleDragEnd(): void {
|
||||
// Clean up visual state
|
||||
const allBlocks = this.component.editorContentRef.querySelectorAll('.block-wrapper');
|
||||
allBlocks.forEach((block: HTMLElement) => {
|
||||
block.classList.remove('dragging', 'move-up', 'move-down');
|
||||
block.style.removeProperty('--drag-offset');
|
||||
block.style.removeProperty('transform');
|
||||
});
|
||||
|
||||
// Remove dragging class from editor
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.editorContentRef.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
this.draggedBlockId = null;
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.draggedBlockElement = null;
|
||||
this.draggedBlockHeight = 0;
|
||||
this.initialBlockY = 0;
|
||||
|
||||
// Update component state
|
||||
this.component.draggedBlockId = null;
|
||||
this.component.dragOverBlockId = null;
|
||||
this.component.dragOverPosition = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag over
|
||||
*/
|
||||
handleDragOver(e: DragEvent, block: IBlock): void {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
this.dragOverBlockId = block.id;
|
||||
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
||||
|
||||
// Update component state
|
||||
this.component.dragOverBlockId = this.dragOverBlockId;
|
||||
this.component.dragOverPosition = this.dragOverPosition;
|
||||
|
||||
// The parent component already handles drag-over classes programmatically
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag leave
|
||||
*/
|
||||
handleDragLeave(block: IBlock): void {
|
||||
if (this.dragOverBlockId === block.id) {
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
|
||||
// Update component state
|
||||
this.component.dragOverBlockId = null;
|
||||
this.component.dragOverPosition = null;
|
||||
|
||||
// The parent component already handles removing drag-over classes programmatically
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drop
|
||||
*/
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
|
||||
|
||||
// The parent component already has a handleDrop method that handles this programmatically
|
||||
// We'll delegate to that to ensure proper programmatic rendering
|
||||
this.component.handleDrop(e, targetBlock);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a block is being dragged
|
||||
*/
|
||||
isDragging(blockId: string): boolean {
|
||||
return this.draggedBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block has drag over state
|
||||
*/
|
||||
isDragOver(blockId: string): boolean {
|
||||
return this.dragOverBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets drag over CSS classes for a block
|
||||
*/
|
||||
getDragOverClasses(blockId: string): string {
|
||||
if (!this.isDragOver(blockId)) return '';
|
||||
return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates the drop indicator element
|
||||
*/
|
||||
private createDropIndicator(): void {
|
||||
this.dropIndicator = document.createElement('div');
|
||||
this.dropIndicator.className = 'drop-indicator';
|
||||
this.dropIndicator.style.display = 'none';
|
||||
this.component.editorContentRef.appendChild(this.dropIndicator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global dragover to update dragged block position and move other blocks
|
||||
*/
|
||||
private handleGlobalDragOver = (e: DragEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.draggedBlockElement) return;
|
||||
|
||||
// Calculate vertical offset from initial position
|
||||
const deltaY = e.clientY - this.initialMouseY;
|
||||
|
||||
// Apply transform to move the dragged block vertically
|
||||
this.draggedBlockElement.style.transform = `translateY(${deltaY}px)`;
|
||||
|
||||
// Throttle position updates to reduce stuttering
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < this.updateThrottle) {
|
||||
return;
|
||||
}
|
||||
this.lastUpdateTime = now;
|
||||
|
||||
// Calculate which blocks should move
|
||||
this.updateBlockPositions(e.clientY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates block positions based on cursor position
|
||||
*/
|
||||
private updateBlockPositions(mouseY: number): void {
|
||||
const blocks = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
|
||||
const draggedIndex = blocks.findIndex(b => b.getAttribute('data-block-id') === this.draggedBlockId);
|
||||
|
||||
if (draggedIndex === -1) return;
|
||||
|
||||
// Reset all transforms first (except the dragged block)
|
||||
blocks.forEach(block => {
|
||||
if (block.getAttribute('data-block-id') !== this.draggedBlockId) {
|
||||
block.classList.remove('move-up', 'move-down');
|
||||
block.style.removeProperty('--drag-offset');
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate where the dragged block should be inserted
|
||||
let newIndex = blocks.length; // Default to end
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue;
|
||||
|
||||
const block = blocks[i];
|
||||
const rect = block.getBoundingClientRect();
|
||||
const blockTop = rect.top;
|
||||
|
||||
// Check if mouse is above this block's middle
|
||||
if (mouseY < blockTop + (rect.height * 0.5)) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply transforms to move blocks out of the way
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue;
|
||||
|
||||
const block = blocks[i];
|
||||
|
||||
// Determine if this block needs to move
|
||||
if (draggedIndex < newIndex) {
|
||||
// Dragging down: blocks between original and new position move up
|
||||
if (i > draggedIndex && i < newIndex) {
|
||||
block.classList.add('move-up');
|
||||
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
|
||||
}
|
||||
} else if (draggedIndex > newIndex) {
|
||||
// Dragging up: blocks between new and original position move down
|
||||
if (i >= newIndex && i < draggedIndex) {
|
||||
block.classList.add('move-down');
|
||||
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update drop indicator position
|
||||
this.updateDropIndicator(blocks, newIndex, draggedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the drop indicator position
|
||||
*/
|
||||
private updateDropIndicator(blocks: HTMLElement[], targetIndex: number, draggedIndex: number): void {
|
||||
if (!this.dropIndicator || !this.draggedBlockElement) return;
|
||||
|
||||
this.dropIndicator.style.display = 'block';
|
||||
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
|
||||
|
||||
const containerRect = this.component.editorContentRef.getBoundingClientRect();
|
||||
// Calculate where the block will actually land
|
||||
let topPosition = 0;
|
||||
|
||||
if (targetIndex === 0) {
|
||||
// Before first block
|
||||
topPosition = 0;
|
||||
} else {
|
||||
// After a specific block
|
||||
const prevIndex = targetIndex - 1;
|
||||
let blockCount = 0;
|
||||
|
||||
// Find the visual position of the block that will be before our dropped block
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue; // Skip the dragged block
|
||||
|
||||
if (blockCount === prevIndex) {
|
||||
const rect = blocks[i].getBoundingClientRect();
|
||||
topPosition = rect.bottom - containerRect.top + 16; // 16px gap
|
||||
break;
|
||||
}
|
||||
blockCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.dropIndicator.style.top = `${topPosition}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global drag end
|
||||
*/
|
||||
private handleGlobalDragEnd = (): void => {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('dragover', this.handleGlobalDragOver);
|
||||
document.removeEventListener('dragend', this.handleGlobalDragEnd);
|
||||
|
||||
// Remove drop indicator
|
||||
if (this.dropIndicator) {
|
||||
this.dropIndicator.remove();
|
||||
this.dropIndicator = null;
|
||||
}
|
||||
|
||||
// Trigger the actual drop if we have a dragged block
|
||||
if (this.draggedBlockId) {
|
||||
// Small delay to ensure transforms are applied
|
||||
requestAnimationFrame(() => {
|
||||
this.performDrop();
|
||||
// Call the regular drag end handler after drop
|
||||
this.handleDragEnd();
|
||||
});
|
||||
} else {
|
||||
// Call the regular drag end handler
|
||||
this.handleDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs the actual drop operation
|
||||
*/
|
||||
private performDrop(): void {
|
||||
if (!this.draggedBlockId) return;
|
||||
|
||||
// Get the visual order of blocks based on their positions
|
||||
const blockElements = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
|
||||
const draggedElement = blockElements.find(el => el.getAttribute('data-block-id') === this.draggedBlockId);
|
||||
|
||||
if (!draggedElement) return;
|
||||
|
||||
|
||||
// Create an array of blocks with their visual positions
|
||||
const visualOrder = blockElements.map(el => {
|
||||
const id = el.getAttribute('data-block-id');
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
return { id, centerY, element: el };
|
||||
});
|
||||
|
||||
// Sort by visual Y position
|
||||
visualOrder.sort((a, b) => a.centerY - b.centerY);
|
||||
|
||||
// Get the new order of block IDs
|
||||
const newBlockIds = visualOrder.map(item => item.id).filter(id => id !== null);
|
||||
|
||||
// Find the original block data
|
||||
const originalBlocks = [...this.component.blocks];
|
||||
const draggedBlock = originalBlocks.find(b => b.id === this.draggedBlockId);
|
||||
|
||||
if (!draggedBlock) return;
|
||||
|
||||
// Check if order actually changed
|
||||
const oldOrder = originalBlocks.map(b => b.id);
|
||||
const orderChanged = !newBlockIds.every((id, index) => id === oldOrder[index]);
|
||||
|
||||
if (!orderChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder blocks based on visual positions
|
||||
const newBlocks = newBlockIds.map(id => originalBlocks.find(b => b.id === id)!).filter(Boolean);
|
||||
|
||||
// Update blocks
|
||||
this.component.blocks = newBlocks;
|
||||
|
||||
// Re-render blocks programmatically
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
|
||||
// Focus the moved block after a delay
|
||||
setTimeout(() => {
|
||||
if (draggedBlock.type !== 'divider') {
|
||||
this.component.blockOperations.focusBlock(draggedBlock.id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
369
ts_web/elements/wysiwyg/wysiwyg.formatting.ts
Normal file
369
ts_web/elements/wysiwyg/wysiwyg.formatting.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
export interface IFormatButton {
|
||||
command: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles text formatting with smart toggle behavior:
|
||||
* - If selection contains ANY instance of a format, removes ALL instances
|
||||
* - If selection has no formatting, applies the format
|
||||
* - Works correctly with Shadow DOM using range-based operations
|
||||
*/
|
||||
export class WysiwygFormatting {
|
||||
static readonly formatButtons: IFormatButton[] = [
|
||||
{ command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' },
|
||||
{ command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' },
|
||||
{ command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' },
|
||||
{ command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' },
|
||||
{ command: 'code', icon: '{ }', label: 'Inline Code' },
|
||||
{ command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' },
|
||||
];
|
||||
|
||||
static renderFormattingMenu(
|
||||
position: { x: number; y: number },
|
||||
onFormat: (command: string) => void
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="formatting-menu"
|
||||
style="top: ${position.y}px; left: ${position.x}px;"
|
||||
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
|
||||
@click="${(e: MouseEvent) => e.stopPropagation()}"
|
||||
>
|
||||
${this.formatButtons.map(button => html`
|
||||
<button
|
||||
class="format-button ${button.command}"
|
||||
@click="${() => onFormat(button.command)}"
|
||||
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||
>
|
||||
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static applyFormat(command: string, value?: string, range?: Range, shadowRoots?: ShadowRoot[]): boolean {
|
||||
// If range is provided, use it directly (Shadow DOM case)
|
||||
// Otherwise fall back to window.getSelection()
|
||||
let workingRange: Range;
|
||||
|
||||
if (range) {
|
||||
workingRange = range;
|
||||
} else {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
workingRange = selection.getRangeAt(0);
|
||||
}
|
||||
|
||||
// Apply format based on command
|
||||
switch (command) {
|
||||
case 'bold':
|
||||
this.wrapSelection(workingRange, 'strong');
|
||||
break;
|
||||
|
||||
case 'italic':
|
||||
this.wrapSelection(workingRange, 'em');
|
||||
break;
|
||||
|
||||
case 'underline':
|
||||
this.wrapSelection(workingRange, 'u');
|
||||
break;
|
||||
|
||||
case 'strikeThrough':
|
||||
this.wrapSelection(workingRange, 's');
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
this.wrapSelection(workingRange, 'code');
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
// Don't use prompt - return false to indicate we need async input
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
this.wrapSelectionWithLink(workingRange, value);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we have shadow roots, use our Shadow DOM selection utility
|
||||
if (shadowRoots && shadowRoots.length > 0) {
|
||||
WysiwygSelection.setSelectionFromRange(workingRange);
|
||||
} else {
|
||||
// Regular selection restoration
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(workingRange);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static wrapSelection(range: Range, tagName: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Check if ANY part of the selection contains this formatting
|
||||
const hasFormatting = this.selectionContainsTag(range, tagName);
|
||||
|
||||
if (hasFormatting) {
|
||||
// Remove all instances of this tag from the selection
|
||||
this.removeTagFromSelection(range, tagName);
|
||||
} else {
|
||||
// Wrap selection with the tag
|
||||
const wrapper = document.createElement(tagName);
|
||||
try {
|
||||
// Extract and wrap contents
|
||||
const contents = range.extractContents();
|
||||
wrapper.appendChild(contents);
|
||||
range.insertNode(wrapper);
|
||||
|
||||
// Select the wrapped content
|
||||
range.selectNodeContents(wrapper);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to wrap selection:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selection contains or is within any instances of a tag
|
||||
*/
|
||||
private static selectionContainsTag(range: Range, tagName: string): boolean {
|
||||
// First check: Are we inside a tag? (even if selection doesn't include the tag)
|
||||
let node: Node | null = range.startContainer;
|
||||
|
||||
while (node && node !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.tagName.toLowerCase() === tagName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
// Also check the end container
|
||||
node = range.endContainer;
|
||||
|
||||
while (node && node !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.tagName.toLowerCase() === tagName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
// Second check: Does the selection contain any complete tags?
|
||||
const tempDiv = document.createElement('div');
|
||||
const contents = range.cloneContents();
|
||||
tempDiv.appendChild(contents);
|
||||
const tags = tempDiv.getElementsByTagName(tagName);
|
||||
|
||||
return tags.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all instances of a tag from the selection
|
||||
*/
|
||||
private static removeTagFromSelection(range: Range, tagName: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Special handling: Check if we need to expand the selection to include parent tags
|
||||
let expandedRange = range.cloneRange();
|
||||
|
||||
// Check if start is inside a tag
|
||||
let startNode: Node | null = range.startContainer;
|
||||
let startTag: Element | null = null;
|
||||
while (startNode && startNode !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (startNode.nodeType === Node.ELEMENT_NODE && (startNode as Element).tagName.toLowerCase() === tagName) {
|
||||
startTag = startNode as Element;
|
||||
break;
|
||||
}
|
||||
startNode = startNode.parentNode;
|
||||
}
|
||||
|
||||
// Check if end is inside a tag
|
||||
let endNode: Node | null = range.endContainer;
|
||||
let endTag: Element | null = null;
|
||||
while (endNode && endNode !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (endNode.nodeType === Node.ELEMENT_NODE && (endNode as Element).tagName.toLowerCase() === tagName) {
|
||||
endTag = endNode as Element;
|
||||
break;
|
||||
}
|
||||
endNode = endNode.parentNode;
|
||||
}
|
||||
|
||||
// Expand range to include the tags if needed
|
||||
if (startTag) {
|
||||
expandedRange.setStartBefore(startTag);
|
||||
}
|
||||
if (endTag) {
|
||||
expandedRange.setEndAfter(endTag);
|
||||
}
|
||||
|
||||
// Extract the contents using the expanded range
|
||||
const fragment = expandedRange.extractContents();
|
||||
|
||||
// Process the fragment to remove tags
|
||||
const processedFragment = this.removeTagsFromFragment(fragment, tagName);
|
||||
|
||||
// Insert the processed content back
|
||||
expandedRange.insertNode(processedFragment);
|
||||
|
||||
// Restore selection to match the original selection intent
|
||||
// Find the text nodes that correspond to the original selection
|
||||
const textNodes: Node[] = [];
|
||||
const walker = document.createTreeWalker(
|
||||
processedFragment,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
if (textNodes.length > 0) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(textNodes[0], 0);
|
||||
newRange.setEnd(textNodes[textNodes.length - 1], textNodes[textNodes.length - 1].textContent?.length || 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all instances of a tag from a document fragment
|
||||
*/
|
||||
private static removeTagsFromFragment(fragment: DocumentFragment, tagName: string): DocumentFragment {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(fragment);
|
||||
|
||||
// Find all instances of the tag
|
||||
const tags = tempDiv.getElementsByTagName(tagName);
|
||||
|
||||
// Convert to array to avoid live collection issues
|
||||
const tagArray = Array.from(tags);
|
||||
|
||||
// Unwrap each tag
|
||||
tagArray.forEach(tag => {
|
||||
const parent = tag.parentNode;
|
||||
if (parent) {
|
||||
// Move all children out of the tag
|
||||
while (tag.firstChild) {
|
||||
parent.insertBefore(tag.firstChild, tag);
|
||||
}
|
||||
// Remove the empty tag
|
||||
parent.removeChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new fragment from the processed content
|
||||
const newFragment = document.createDocumentFragment();
|
||||
while (tempDiv.firstChild) {
|
||||
newFragment.appendChild(tempDiv.firstChild);
|
||||
}
|
||||
|
||||
return newFragment;
|
||||
}
|
||||
|
||||
private static wrapSelectionWithLink(range: Range, url: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// First remove any existing links in the selection
|
||||
if (this.selectionContainsTag(range, 'a')) {
|
||||
this.removeTagFromSelection(range, 'a');
|
||||
// Re-get the range after modification
|
||||
if (selection.rangeCount > 0) {
|
||||
range = selection.getRangeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
try {
|
||||
const contents = range.extractContents();
|
||||
link.appendChild(contents);
|
||||
range.insertNode(link);
|
||||
|
||||
// Select the link
|
||||
range.selectNodeContents(link);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to create link:', e);
|
||||
}
|
||||
}
|
||||
|
||||
static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
|
||||
// Get selection info using the new utility that handles Shadow DOM
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
|
||||
|
||||
if (!selectionInfo) {
|
||||
console.log('No selection info available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a range from the selection info to get bounding rect
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
console.log('Range rect:', rect);
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
console.log('Rect width and height are 0, trying different approach');
|
||||
// Sometimes the rect is collapsed, let's try getting the caret position
|
||||
if ('caretPositionFromPoint' in document) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const tempSpan = document.createElement('span');
|
||||
tempSpan.textContent = '\u200B'; // Zero-width space
|
||||
range.insertNode(tempSpan);
|
||||
const spanRect = tempSpan.getBoundingClientRect();
|
||||
tempSpan.remove();
|
||||
|
||||
if (spanRect.width > 0 || spanRect.height > 0) {
|
||||
const coords = {
|
||||
x: spanRect.left,
|
||||
y: Math.max(45, spanRect.top - 45)
|
||||
};
|
||||
console.log('Used span trick for coords:', coords);
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = {
|
||||
x: rect.left + (rect.width / 2),
|
||||
y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative
|
||||
};
|
||||
|
||||
console.log('Returning coords:', coords);
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
167
ts_web/elements/wysiwyg/wysiwyg.history.ts
Normal file
167
ts_web/elements/wysiwyg/wysiwyg.history.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export interface IHistoryState {
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
cursorPosition?: {
|
||||
blockId: string;
|
||||
offset: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class WysiwygHistory {
|
||||
private history: IHistoryState[] = [];
|
||||
private currentIndex: number = -1;
|
||||
private maxHistorySize: number = 50;
|
||||
private lastSaveTime: number = 0;
|
||||
private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots
|
||||
|
||||
constructor() {
|
||||
// Initialize with empty state
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to history
|
||||
*/
|
||||
saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Debounce rapid changes (like typing)
|
||||
if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) {
|
||||
// Update the current state instead of creating a new one
|
||||
this.history[this.currentIndex] = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any states after current index (when we save after undoing)
|
||||
if (this.currentIndex < this.history.length - 1) {
|
||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
// Add new state
|
||||
const newState: IHistoryState = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
this.history.push(newState);
|
||||
this.currentIndex++;
|
||||
|
||||
// Limit history size
|
||||
if (this.history.length > this.maxHistorySize) {
|
||||
this.history.shift();
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
this.lastSaveTime = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save a checkpoint (useful for operations like block deletion)
|
||||
*/
|
||||
saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
this.lastSaveTime = 0; // Reset debounce
|
||||
this.saveState(blocks, selectedBlockId, cursorPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo to previous state
|
||||
*/
|
||||
undo(): IHistoryState | null {
|
||||
if (!this.canUndo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex--;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo to next state
|
||||
*/
|
||||
redo(): IHistoryState | null {
|
||||
if (!this.canRedo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo is available
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.currentIndex > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo is available
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.currentIndex < this.history.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getCurrentState(): IHistoryState | null {
|
||||
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history
|
||||
*/
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone blocks
|
||||
*/
|
||||
private cloneBlocks(blocks: IBlock[]): IBlock[] {
|
||||
return blocks.map(block => ({
|
||||
...block,
|
||||
metadata: block.metadata ? { ...block.metadata } : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a history state
|
||||
*/
|
||||
private cloneState(state: IHistoryState): IHistoryState {
|
||||
return {
|
||||
blocks: this.cloneBlocks(state.blocks),
|
||||
selectedBlockId: state.selectedBlockId,
|
||||
cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined,
|
||||
timestamp: state.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history info for debugging
|
||||
*/
|
||||
getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } {
|
||||
return {
|
||||
size: this.history.length,
|
||||
currentIndex: this.currentIndex,
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo()
|
||||
};
|
||||
}
|
||||
}
|
||||
302
ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts
Normal file
302
ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||||
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
|
||||
|
||||
export class WysiwygInputHandler {
|
||||
private component: IWysiwygComponent;
|
||||
private saveTimeout: any = null;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input events for blocks
|
||||
*/
|
||||
handleBlockInput(e: InputEvent, block: IBlock): void {
|
||||
if (this.component.isComposing) return;
|
||||
|
||||
const target = e.target as HTMLDivElement;
|
||||
const textContent = target.textContent || '';
|
||||
|
||||
// Check for block type transformations BEFORE updating content
|
||||
const detectedType = this.detectBlockTypeIntent(textContent);
|
||||
if (detectedType && detectedType.type !== block.type) {
|
||||
e.preventDefault();
|
||||
this.handleBlockTransformation(block, detectedType, target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
this.handleSlashCommand(textContent, target);
|
||||
|
||||
// Don't update block content immediately - let the block handle its own content
|
||||
// This prevents re-renders during typing
|
||||
|
||||
// Schedule auto-save (which will sync content later)
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates block content based on its type
|
||||
*/
|
||||
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
||||
// Get the block component for proper content extraction
|
||||
const wrapperElement = target.closest('.block-wrapper');
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent) {
|
||||
// Use the block component's getContent method for consistency
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed to avoid unnecessary updates
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
|
||||
// Update list metadata if needed
|
||||
if (block.type === 'list') {
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback if block component not found
|
||||
if (block.type === 'list') {
|
||||
const listItems = target.querySelectorAll('li');
|
||||
// Use innerHTML to preserve formatting
|
||||
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
} else if (block.type === 'code') {
|
||||
block.content = target.textContent || '';
|
||||
} else {
|
||||
block.content = target.innerHTML || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the user is trying to create a specific block type
|
||||
*/
|
||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
||||
// Check heading patterns
|
||||
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
||||
if (headingResult) {
|
||||
return headingResult;
|
||||
}
|
||||
|
||||
// Check list patterns
|
||||
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
||||
if (listResult) {
|
||||
return listResult;
|
||||
}
|
||||
|
||||
// Check quote pattern
|
||||
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
||||
return { type: 'quote' };
|
||||
}
|
||||
|
||||
// Check code pattern
|
||||
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
||||
return { type: 'code' };
|
||||
}
|
||||
|
||||
// Check divider pattern
|
||||
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
||||
return { type: 'divider' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles block type transformation
|
||||
*/
|
||||
private async handleBlockTransformation(
|
||||
block: IBlock,
|
||||
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
|
||||
target: HTMLDivElement
|
||||
): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
if (detectedType.type === 'list') {
|
||||
block.type = 'list';
|
||||
block.content = '';
|
||||
block.metadata = { listType: detectedType.listType };
|
||||
|
||||
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
||||
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
WysiwygBlocks.focusListItem(target);
|
||||
}, 0);
|
||||
} else if (detectedType.type === 'divider') {
|
||||
block.type = 'divider';
|
||||
block.content = ' ';
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
this.component.updateValue();
|
||||
} else if (detectedType.type === 'code') {
|
||||
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.type = 'code';
|
||||
block.content = '';
|
||||
block.metadata = { language };
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
// Focus the code block
|
||||
setTimeout(async () => {
|
||||
await blockOps.focusBlock(block.id, 'start');
|
||||
}, 50);
|
||||
}
|
||||
} else {
|
||||
block.type = detectedType.type;
|
||||
block.content = '';
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
// Focus the transformed block
|
||||
setTimeout(async () => {
|
||||
await blockOps.focusBlock(block.id, 'start');
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash command detection and menu display
|
||||
*/
|
||||
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
||||
const slashMenu = this.component.slashMenu;
|
||||
const isSlashMenuVisible = slashMenu && slashMenu.visible;
|
||||
|
||||
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
|
||||
if (!isSlashMenuVisible && textContent === '/') {
|
||||
// Get position for menu based on cursor location
|
||||
const rect = this.getCaretCoordinates(target);
|
||||
|
||||
// Show the slash menu at the cursor position
|
||||
slashMenu.show(
|
||||
{ x: rect.left, y: rect.bottom + 4 },
|
||||
(type: string) => {
|
||||
this.component.insertBlock(type);
|
||||
}
|
||||
);
|
||||
|
||||
// Ensure the block maintains focus
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement !== target) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Update filter
|
||||
if (slashMenu) {
|
||||
slashMenu.updateFilter(textContent.slice(1));
|
||||
}
|
||||
} else if (!textContent.startsWith('/')) {
|
||||
this.component.closeSlashMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the coordinates of the caret/cursor
|
||||
*/
|
||||
private getCaretCoordinates(element: HTMLElement): DOMRect {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to element position
|
||||
return element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules auto-save after a delay
|
||||
*/
|
||||
private scheduleAutoSave(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
// Don't auto-save if slash menu is open
|
||||
if (this.component.slashMenu && this.component.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
// Sync all block content from DOM before saving
|
||||
this.syncAllBlockContent();
|
||||
// Only update value, don't trigger any re-renders
|
||||
this.component.updateValue();
|
||||
// Don't call requestUpdate() as it's not needed
|
||||
}, 2000); // Increased delay to reduce interference with typing
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs content from all block DOMs to the data model
|
||||
*/
|
||||
private syncAllBlockContent(): void {
|
||||
this.component.blocks.forEach((block: IBlock) => {
|
||||
const wrapperElement = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getContent) {
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal file
88
ts_web/elements/wysiwyg/wysiwyg.interfaces.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { DeesSlashMenu } from './dees-slash-menu.js';
|
||||
import { DeesFormattingMenu } from './dees-formatting-menu.js';
|
||||
|
||||
/**
|
||||
* Interface for the main wysiwyg component
|
||||
*/
|
||||
export interface IWysiwygComponent {
|
||||
// State
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
editorContentRef: HTMLDivElement;
|
||||
draggedBlockId: string | null;
|
||||
dragOverBlockId: string | null;
|
||||
dragOverPosition: 'before' | 'after' | null;
|
||||
isComposing: boolean;
|
||||
|
||||
// Menus
|
||||
slashMenu: DeesSlashMenu;
|
||||
formattingMenu: DeesFormattingMenu;
|
||||
|
||||
// Methods
|
||||
updateValue(): void;
|
||||
requestUpdate(): void;
|
||||
updateComplete: Promise<boolean>;
|
||||
insertBlock(type: string): Promise<void>;
|
||||
closeSlashMenu(clearSlash?: boolean): void;
|
||||
applyFormat(command: string): Promise<void>;
|
||||
handleSlashMenuKeyboard(e: KeyboardEvent): void;
|
||||
createBlockElement(block: IBlock): HTMLElement;
|
||||
updateBlockElement(blockId: string): void;
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
||||
renderBlocksProgrammatically(): void;
|
||||
saveToHistory(debounce?: boolean): void;
|
||||
|
||||
// Handlers
|
||||
blockOperations: IBlockOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block operations
|
||||
*/
|
||||
export interface IBlockOperations {
|
||||
createBlock(type?: IBlock['type'], content?: string, metadata?: any): IBlock;
|
||||
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock?: boolean): Promise<void>;
|
||||
removeBlock(blockId: string): void;
|
||||
findBlock(blockId: string): IBlock | undefined;
|
||||
getBlockIndex(blockId: string): number;
|
||||
focusBlock(blockId: string, cursorPosition?: 'start' | 'end' | number): Promise<void>;
|
||||
updateBlockContent(blockId: string, content: string): void;
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void;
|
||||
moveBlock(blockId: string, targetIndex: number): void;
|
||||
getPreviousBlock(blockId: string): IBlock | null;
|
||||
getNextBlock(blockId: string): IBlock | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block component
|
||||
*/
|
||||
export interface IWysiwygBlockComponent {
|
||||
block: IBlock;
|
||||
isSelected: boolean;
|
||||
blockElement: HTMLDivElement | null;
|
||||
|
||||
focus(): void;
|
||||
focusWithCursor(position: 'start' | 'end' | number): void;
|
||||
getContent(): string;
|
||||
setContent(content: string): void;
|
||||
setCursorToStart(): void;
|
||||
setCursorToEnd(): void;
|
||||
focusListItem(): void;
|
||||
getSplitContent(splitPosition: number): { before: string; after: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler interfaces
|
||||
*/
|
||||
export interface IBlockEventHandlers {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
}
|
||||
806
ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
806
ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
export class WysiwygKeyboardHandler {
|
||||
private component: IWysiwygComponent;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard events for blocks
|
||||
*/
|
||||
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// Handle slash menu navigation
|
||||
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
|
||||
this.component.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle formatting shortcuts
|
||||
if (this.handleFormattingShortcuts(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
this.handleTab(e, block);
|
||||
break;
|
||||
case 'Enter':
|
||||
await this.handleEnter(e, block);
|
||||
break;
|
||||
case 'Backspace':
|
||||
await this.handleBackspace(e, block);
|
||||
break;
|
||||
case 'Delete':
|
||||
await this.handleDelete(e, block);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
await this.handleArrowUp(e, block);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
await this.handleArrowDown(e, block);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
await this.handleArrowLeft(e, block);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
await this.handleArrowRight(e, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if key is for slash menu navigation
|
||||
*/
|
||||
private isSlashMenuKey(key: string): boolean {
|
||||
return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles formatting keyboard shortcuts
|
||||
*/
|
||||
private handleFormattingShortcuts(e: KeyboardEvent): boolean {
|
||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
// Use Promise to ensure focus is maintained
|
||||
Promise.resolve().then(() => this.component.applyFormat('bold'));
|
||||
return true;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('italic'));
|
||||
return true;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('underline'));
|
||||
return true;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('link'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Tab key
|
||||
*/
|
||||
private handleTab(e: KeyboardEvent, block: IBlock): void {
|
||||
if (block.type === 'code') {
|
||||
// Allow tab in code blocks
|
||||
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);
|
||||
}
|
||||
} else if (block.type === 'list') {
|
||||
// Future: implement list indentation
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key
|
||||
*/
|
||||
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// For non-editable blocks, create a new paragraph after
|
||||
if (block.type === 'divider' || block.type === 'image') {
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter in code blocks creates a new block
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Normal Enter in code blocks creates new line (let browser handle it)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.shiftKey) {
|
||||
if (block.type === 'list') {
|
||||
await this.handleEnterInList(e, block);
|
||||
} else {
|
||||
// Split content at cursor position
|
||||
e.preventDefault();
|
||||
|
||||
console.log('Enter key pressed in block:', {
|
||||
blockId: block.id,
|
||||
blockType: block.type,
|
||||
blockContent: block.content,
|
||||
blockContentLength: block.content?.length || 0,
|
||||
eventTarget: e.target,
|
||||
eventTargetTagName: (e.target as HTMLElement).tagName
|
||||
});
|
||||
|
||||
// Get the block component - need to search in the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
console.log('Found block wrapper:', blockWrapper);
|
||||
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent);
|
||||
|
||||
if (blockComponent && blockComponent.getSplitContent) {
|
||||
console.log('Calling getSplitContent...');
|
||||
const splitContent = blockComponent.getSplitContent();
|
||||
|
||||
console.log('Enter key split content result:', {
|
||||
hasSplitContent: !!splitContent,
|
||||
beforeLength: splitContent?.before?.length || 0,
|
||||
afterLength: splitContent?.after?.length || 0,
|
||||
splitContent
|
||||
});
|
||||
|
||||
if (splitContent) {
|
||||
console.log('Updating current block with before content...');
|
||||
// Update current block with content before cursor
|
||||
blockComponent.setContent(splitContent.before);
|
||||
block.content = splitContent.before;
|
||||
|
||||
console.log('Creating new block with after content...');
|
||||
// Create new block with content after cursor
|
||||
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
||||
|
||||
console.log('Inserting new block...');
|
||||
// Insert the new block
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
// Update the value after both blocks are set
|
||||
this.component.updateValue();
|
||||
console.log('Enter key handling complete');
|
||||
} else {
|
||||
// Fallback - just create empty block
|
||||
console.log('No split content returned, creating empty block');
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
} else {
|
||||
// No block component or method, just create empty block
|
||||
console.log('No getSplitContent method, creating empty block');
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shift+Enter creates line break (let browser handle it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key in list blocks
|
||||
*/
|
||||
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
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();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Otherwise, let browser create new list item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Backspace key
|
||||
*/
|
||||
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// Handle non-editable blocks
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
|
||||
// If it's the only block, delete it and create a new paragraph
|
||||
if (this.component.blocks.length === 1) {
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Create a new paragraph block
|
||||
const newBlock = blockOps.createBlock('paragraph', '');
|
||||
this.component.blocks = [newBlock];
|
||||
|
||||
// Re-render blocks
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Focus the new block
|
||||
await blockOps.focusBlock(newBlock.id, 'start');
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Find the previous block to focus
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus the appropriate block
|
||||
if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
} else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') {
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
} else if (prevBlock) {
|
||||
// If previous block is also non-editable, just select it
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
} else if (nextBlock) {
|
||||
// If next block is also non-editable, just select it
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component to check cursor position
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get cursor position
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
|
||||
const actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
if (cursorPos === 0) {
|
||||
e.preventDefault();
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
// If previous block is non-editable, select it first
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(prevBlock.type)) {
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Save checkpoint for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Special handling for different block types
|
||||
if (prevBlock.type === 'code' && block.type !== 'code') {
|
||||
// Can't merge non-code into code block, just remove empty block
|
||||
if (block.content === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === 'code' && prevBlock.type !== 'code') {
|
||||
// Can't merge code into non-code block
|
||||
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
if (actualContent === '' || actualContent.trim() === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the content of both blocks
|
||||
const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`);
|
||||
const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
const prevContent = prevBlockComponent?.getContent() || prevBlock.content || '';
|
||||
const currentContent = blockComponent.getContent() || block.content || '';
|
||||
|
||||
// Merge content
|
||||
let mergedContent = '';
|
||||
if (prevBlock.type === 'code' && block.type === 'code') {
|
||||
// For code blocks, join with newline
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else if (prevBlock.type === 'list' && block.type === 'list') {
|
||||
// For lists, combine the list items
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else {
|
||||
// For other blocks, join with space if both have content
|
||||
mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent;
|
||||
}
|
||||
|
||||
// Store cursor position (where the merge point is)
|
||||
const mergePoint = prevContent.length;
|
||||
|
||||
// Update previous block with merged content
|
||||
blockOps.updateBlockContent(prevBlock.id, mergedContent);
|
||||
if (prevBlockComponent) {
|
||||
prevBlockComponent.setContent(mergedContent);
|
||||
}
|
||||
|
||||
// Remove current block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus previous block at merge point
|
||||
await blockOps.focusBlock(prevBlock.id, mergePoint);
|
||||
}
|
||||
} else if (this.component.blocks.length > 1) {
|
||||
// Check if block is actually empty by getting current content from DOM
|
||||
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
|
||||
if (currentContent === '' || currentContent.trim() === '') {
|
||||
// Empty block - just remove it
|
||||
e.preventDefault();
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
if (prevBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal backspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Delete key
|
||||
*/
|
||||
private async handleDelete(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// Handle non-editable blocks - same as backspace
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
|
||||
// If it's the only block, delete it and create a new paragraph
|
||||
if (this.component.blocks.length === 1) {
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Create a new paragraph block
|
||||
const newBlock = blockOps.createBlock('paragraph', '');
|
||||
this.component.blocks = [newBlock];
|
||||
|
||||
// Re-render blocks
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Focus the new block
|
||||
await blockOps.focusBlock(newBlock.id, 'start');
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Find the previous block to focus
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus the appropriate block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) {
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
} else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
} else if (nextBlock) {
|
||||
// If next block is also non-editable, just select it
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
} else if (prevBlock) {
|
||||
// If previous block is also non-editable, just select it
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For editable blocks, check if we're at the end and next block is non-editable
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get cursor position
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
const textLength = target.textContent?.length || 0;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
if (cursorPos === textLength) {
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nextBlock && nonEditableTypes.includes(nextBlock.type)) {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, let browser handle normal delete
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowUp key - navigate to previous block if at beginning or first line
|
||||
*/
|
||||
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, always navigate to previous block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if we're on the first line
|
||||
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowDown key - navigate to next block if at end or last line
|
||||
*/
|
||||
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, always navigate to next block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if we're on the last line
|
||||
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowLeft key - navigate to previous block if at beginning
|
||||
*/
|
||||
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, navigate to previous block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
|
||||
if (cursorPos === 0) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
e.preventDefault();
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
|
||||
await blockOps.focusBlock(prevBlock.id, position);
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal left arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowRight key - navigate to next block if at end
|
||||
*/
|
||||
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, navigate to next block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element (code blocks have .block.code)
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
const textLength = target.textContent?.length || 0;
|
||||
|
||||
if (cursorPos === textLength) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
e.preventDefault();
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal right arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash menu keyboard navigation
|
||||
* Note: This is now handled by the component directly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if cursor is on the first line of a block
|
||||
*/
|
||||
private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the top position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the top (within 5px tolerance for line height variations)
|
||||
const isNearTop = rect.top - containerRect.top < 5;
|
||||
|
||||
// For single-line content, also check if we're at the beginning
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots);
|
||||
return cursorPos === 0;
|
||||
}
|
||||
|
||||
return isNearTop;
|
||||
} catch (e) {
|
||||
console.warn('Error checking first line:', e);
|
||||
// Fallback to position-based check
|
||||
const cursorPos = selectionInfo.startOffset;
|
||||
return cursorPos === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cursor is on the last line of a block
|
||||
*/
|
||||
private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the bottom position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the bottom (within 5px tolerance for line height variations)
|
||||
const isNearBottom = containerRect.bottom - rect.bottom < 5;
|
||||
|
||||
// For single-line content, also check if we're at the end
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
|
||||
return isNearBottom;
|
||||
} catch (e) {
|
||||
console.warn('Error checking last line:', e);
|
||||
// Fallback to position-based check
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
271
ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts
Normal file
271
ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { DeesModal } from '../dees-modal.js';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
|
||||
|
||||
export class WysiwygModalManager {
|
||||
/**
|
||||
* Shows language selection modal for code blocks
|
||||
*/
|
||||
static async showLanguageSelectionModal(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedLanguage: string | null = null;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Select Programming Language',
|
||||
content: html`
|
||||
<style>
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 12px;
|
||||
background: var(--dees-color-box);
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.language-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
</style>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div class="language-button" @click="${(e: MouseEvent) => {
|
||||
selectedLanguage = lang.toLowerCase();
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const okButton = modal.shadowRoot?.querySelector('.bottomButton.ok') as HTMLElement;
|
||||
if (okButton) okButton.click();
|
||||
}
|
||||
}}">${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'OK',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(selectedLanguage);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows block settings modal
|
||||
*/
|
||||
static async showBlockSettingsModal(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): Promise<void> {
|
||||
const content = html`
|
||||
<style>
|
||||
.settings-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.settings-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--dees-color-text);
|
||||
}
|
||||
.block-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.block-type-button {
|
||||
padding: 12px;
|
||||
background: var(--dees-color-box);
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.block-type-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
.block-type-button.selected {
|
||||
background: var(--dees-color-primary);
|
||||
color: white;
|
||||
}
|
||||
.block-type-icon {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-container">
|
||||
${this.getBlockTypeSelector(block, onUpdate)}
|
||||
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Block Settings',
|
||||
content,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Close',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets code block settings content
|
||||
*/
|
||||
private static getCodeBlockSettings(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const currentLanguage = block.metadata?.language || 'plain text';
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.settings-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 8px;
|
||||
background: var(--dees-color-box);
|
||||
border: 1px solid var(--dees-color-line-bright);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.language-button:hover {
|
||||
background: var(--dees-color-box-highlight);
|
||||
border-color: var(--dees-color-primary);
|
||||
}
|
||||
.language-button.selected {
|
||||
background: var(--dees-color-primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Programming Language</div>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${(e: MouseEvent) => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.language = lang.toLowerCase();
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
|
||||
if (closeButton) closeButton.click();
|
||||
}
|
||||
}}">${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available programming languages
|
||||
*/
|
||||
private static getLanguages(): string[] {
|
||||
return [...PROGRAMMING_LANGUAGES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block type selector
|
||||
*/
|
||||
private static getBlockTypeSelector(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const blockTypes = WysiwygShortcuts.getSlashMenuItems().filter(item => item.type !== 'divider');
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Block Type</div>
|
||||
<div class="block-type-grid">
|
||||
${blockTypes.map(item => html`
|
||||
<div
|
||||
class="block-type-button ${block.type === item.type ? 'selected' : ''}"
|
||||
@click="${async (e: MouseEvent) => {
|
||||
const oldType = block.type;
|
||||
block.type = item.type as IBlock['type'];
|
||||
|
||||
// Reset metadata for type change
|
||||
if (oldType === 'code' && block.type !== 'code') {
|
||||
delete block.metadata?.language;
|
||||
} else if (oldType === 'list' && block.type !== 'list') {
|
||||
delete block.metadata?.listType;
|
||||
} else if (block.type === 'list' && !block.metadata?.listType) {
|
||||
block.metadata = { listType: 'bullet' };
|
||||
} else if (block.type === 'code' && !block.metadata?.language) {
|
||||
// Ask for language if changing to code block
|
||||
const language = await this.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.metadata = { language };
|
||||
} else {
|
||||
// User cancelled, revert
|
||||
block.type = oldType;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal after selection
|
||||
const modal = (e.target as HTMLElement).closest('dees-modal');
|
||||
if (modal) {
|
||||
const closeButton = modal.shadowRoot?.querySelector('.bottomButton') as HTMLElement;
|
||||
if (closeButton) closeButton.click();
|
||||
}
|
||||
}}"
|
||||
>
|
||||
<span class="block-type-icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
283
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal file
283
ts_web/elements/wysiwyg/wysiwyg.selection.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Utilities for handling selection across Shadow DOM boundaries
|
||||
*/
|
||||
|
||||
export interface SelectionInfo {
|
||||
startContainer: Node;
|
||||
startOffset: number;
|
||||
endContainer: Node;
|
||||
endOffset: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// Type for the extended caretPositionFromPoint with Shadow DOM support
|
||||
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
|
||||
|
||||
export class WysiwygSelection {
|
||||
/**
|
||||
* Gets selection info that works across Shadow DOM boundaries
|
||||
* @param shadowRoots - Shadow roots to include in the selection search
|
||||
*/
|
||||
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
|
||||
const selection = window.getSelection();
|
||||
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
|
||||
if (!selection) return null;
|
||||
|
||||
// Try using getComposedRanges if available (better Shadow DOM support)
|
||||
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
|
||||
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
|
||||
try {
|
||||
// Pass shadow roots in the correct format as per MDN
|
||||
const ranges = selection.getComposedRanges({ shadowRoots });
|
||||
console.log('getComposedRanges returned', ranges.length, 'ranges');
|
||||
if (ranges.length > 0) {
|
||||
const range = ranges[0];
|
||||
return {
|
||||
startContainer: range.startContainer,
|
||||
startOffset: range.startOffset,
|
||||
endContainer: range.endContainer,
|
||||
endOffset: range.endOffset,
|
||||
collapsed: range.collapsed
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('getComposedRanges not available, using fallback');
|
||||
}
|
||||
|
||||
// Fallback to traditional selection API
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
return {
|
||||
startContainer: range.startContainer,
|
||||
startOffset: range.startOffset,
|
||||
endContainer: range.endContainer,
|
||||
endOffset: range.endOffset,
|
||||
collapsed: range.collapsed
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a selection is within a specific element (considering Shadow DOM)
|
||||
*/
|
||||
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
|
||||
const selectionInfo = shadowRoot
|
||||
? this.getSelectionInfo(shadowRoot)
|
||||
: this.getSelectionInfo();
|
||||
|
||||
if (!selectionInfo) return false;
|
||||
|
||||
// Check if the selection's common ancestor is within the element
|
||||
return element.contains(selectionInfo.startContainer) ||
|
||||
element.contains(selectionInfo.endContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the selected text across Shadow DOM boundaries
|
||||
*/
|
||||
static getSelectedText(): string {
|
||||
const selection = window.getSelection();
|
||||
return selection ? selection.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a range from selection info
|
||||
*/
|
||||
static createRangeFromInfo(info: SelectionInfo): Range {
|
||||
const range = document.createRange();
|
||||
range.setStart(info.startContainer, info.startOffset);
|
||||
range.setEnd(info.endContainer, info.endOffset);
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets selection from a range (works with Shadow DOM)
|
||||
*/
|
||||
static setSelectionFromRange(range: Range): void {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cursor position relative to a specific element
|
||||
*/
|
||||
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
|
||||
const selectionInfo = shadowRoots.length > 0
|
||||
? this.getSelectionInfo(...shadowRoots)
|
||||
: this.getSelectionInfo();
|
||||
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return null;
|
||||
|
||||
// Create a range from start of element to cursor position
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
|
||||
// Handle case where selection is in a text node that's a child of the element
|
||||
// Use our Shadow DOM-aware contains method
|
||||
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
|
||||
|
||||
if (isContained) {
|
||||
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
const position = range.toString().length;
|
||||
return position;
|
||||
} else {
|
||||
// Selection might be in shadow DOM or different context
|
||||
// Try to find the equivalent position in the element
|
||||
const text = element.textContent || '';
|
||||
const selectionText = selectionInfo.startContainer.textContent || '';
|
||||
|
||||
// If the selection is at the beginning or end, handle those cases
|
||||
if (selectionInfo.startOffset === 0) {
|
||||
return 0;
|
||||
} else if (selectionInfo.startOffset === selectionText.length) {
|
||||
return text.length;
|
||||
}
|
||||
|
||||
// For other cases, try to match based on text content
|
||||
console.warn('Selection container not within element, using text matching fallback');
|
||||
return selectionInfo.startOffset;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get cursor position:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cursor position from mouse coordinates with Shadow DOM support
|
||||
*/
|
||||
static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null {
|
||||
// Try modern API with shadow root support
|
||||
if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) {
|
||||
let caretPos: CaretPosition | null = null;
|
||||
|
||||
// Try with shadow roots first (newer API)
|
||||
try {
|
||||
caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots);
|
||||
} catch (e) {
|
||||
// Fallback to standard API without shadow roots
|
||||
caretPos = document.caretPositionFromPoint(x, y);
|
||||
}
|
||||
|
||||
if (caretPos && container.contains(caretPos.offsetNode)) {
|
||||
// Calculate total offset within the container
|
||||
return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container);
|
||||
}
|
||||
}
|
||||
|
||||
// Safari/WebKit fallback
|
||||
if ('caretRangeFromPoint' in document) {
|
||||
const range = (document as any).caretRangeFromPoint(x, y);
|
||||
if (range && container.contains(range.startContainer)) {
|
||||
return this.getOffsetInElement(range.startContainer, range.startOffset, container);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the total character offset of a position within an element
|
||||
*/
|
||||
private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number {
|
||||
let totalOffset = 0;
|
||||
let found = false;
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let textNode: Node | null;
|
||||
while (textNode = walker.nextNode()) {
|
||||
if (textNode === node) {
|
||||
totalOffset += offset;
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
totalOffset += textNode.textContent?.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return found ? totalOffset : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets cursor position in an element
|
||||
*/
|
||||
static setCursorPosition(element: Element, position: number): void {
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let currentPosition = 0;
|
||||
let targetNode: Text | null = null;
|
||||
let targetOffset = 0;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const nodeLength = node.textContent?.length || 0;
|
||||
|
||||
if (currentPosition + nodeLength >= position) {
|
||||
targetNode = node;
|
||||
targetOffset = position - currentPosition;
|
||||
break;
|
||||
}
|
||||
|
||||
currentPosition += nodeLength;
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
const range = document.createRange();
|
||||
range.setStart(targetNode, targetOffset);
|
||||
range.collapse(true);
|
||||
this.setSelectionFromRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
74
ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts
Normal file
74
ts_web/elements/wysiwyg/wysiwyg.shortcuts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygShortcuts {
|
||||
static readonly HEADING_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^#[\s\u00A0]$/, type: 'heading-1' },
|
||||
{ pattern: /^##[\s\u00A0]$/, type: 'heading-2' },
|
||||
{ pattern: /^###[\s\u00A0]$/, type: 'heading-3' }
|
||||
];
|
||||
|
||||
static readonly LIST_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^[*-][\s\u00A0]$/, type: 'bullet' },
|
||||
{ pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' },
|
||||
{ pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' }
|
||||
];
|
||||
|
||||
static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/;
|
||||
static readonly CODE_PATTERN = /^```$/;
|
||||
static readonly DIVIDER_PATTERNS = ['---', '***', '___'];
|
||||
|
||||
static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null {
|
||||
for (const { pattern, type } of this.HEADING_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: type as IBlock['type'] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null {
|
||||
for (const { pattern, type } of this.LIST_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: 'list', listType: type as 'bullet' | 'ordered' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkQuoteShortcut(content: string): boolean {
|
||||
return this.QUOTE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkCodeShortcut(content: string): boolean {
|
||||
return this.CODE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkDividerShortcut(content: string): boolean {
|
||||
return this.DIVIDER_PATTERNS.includes(content);
|
||||
}
|
||||
|
||||
static getSlashMenuItems(): ISlashMenuItem[] {
|
||||
return [
|
||||
{ type: 'paragraph', label: 'Paragraph', icon: '¶' },
|
||||
{ type: 'heading-1', label: 'Heading 1', icon: 'H₁' },
|
||||
{ type: 'heading-2', label: 'Heading 2', icon: 'H₂' },
|
||||
{ type: 'heading-3', label: 'Heading 3', icon: 'H₃' },
|
||||
{ type: 'quote', label: 'Quote', icon: '"' },
|
||||
{ type: 'code', label: 'Code', icon: '<>' },
|
||||
{ type: 'list', label: 'List', icon: '•' },
|
||||
{ type: 'image', label: 'Image', icon: '🖼' },
|
||||
{ type: 'divider', label: 'Divider', icon: '—' },
|
||||
{ type: 'youtube', label: 'YouTube', icon: '▶️' },
|
||||
{ type: 'markdown', label: 'Markdown', icon: 'M↓' },
|
||||
{ type: 'html', label: 'HTML', icon: '</>' },
|
||||
{ type: 'attachment', label: 'File Attachment', icon: '📎' },
|
||||
];
|
||||
}
|
||||
|
||||
static generateBlockId(): string {
|
||||
return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the type that is used in this module
|
||||
export type { ISlashMenuItem } from './wysiwyg.types.js';
|
||||
575
ts_web/elements/wysiwyg/wysiwyg.styles.ts
Normal file
575
ts_web/elements/wysiwyg/wysiwyg.styles.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const wysiwygStyles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wysiwyg-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#1a1a1a')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
padding: 32px 40px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:hover {
|
||||
border-color: ${cssManager.bdTheme('#d0d0d0', '#444')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:focus-within {
|
||||
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')};
|
||||
}
|
||||
|
||||
/* Visual hint for text selection */
|
||||
.editor-content:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
outline: none;
|
||||
min-height: 160px;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 1.6em;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
}
|
||||
|
||||
/* First and last blocks don't need extra spacing */
|
||||
.block-wrapper:first-child .block {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.block-wrapper:last-child .block {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block.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)')};
|
||||
border-radius: 4px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.block[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.block.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.paragraph:empty::before {
|
||||
content: "Type '/' for commands...";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-1:empty::before {
|
||||
content: "Heading 1";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block.heading-2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-2:empty::before {
|
||||
content: "Heading 2";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.heading-3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-3:empty::before {
|
||||
content: "Heading 3";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.quote {
|
||||
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
padding-left: 20px;
|
||||
font-style: italic;
|
||||
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.quote:empty::before {
|
||||
content: "Quote";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
margin: 20px 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;
|
||||
}
|
||||
|
||||
.block.code {
|
||||
background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')};
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
padding-top: 32px; /* Make room for language indicator */
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: ${cssManager.bdTheme('#24292e', '#e1e4e8')};
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.block.code:empty::before {
|
||||
content: "// Code block";
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
pointer-events: none;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.list {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding: 0 0 0 24px;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.block.list ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.block.list ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block.divider {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
padding: 4px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toolbar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
/* Drag and Drop Styles */
|
||||
.block-wrapper {
|
||||
position: relative;
|
||||
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing context for blocks */
|
||||
.block-wrapper + .block-wrapper .block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Override for headings following other blocks */
|
||||
.block-wrapper + .block-wrapper .block.heading-1,
|
||||
.block-wrapper + .block-wrapper .block.heading-2,
|
||||
.block-wrapper + .block-wrapper .block.heading-3 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Code and quote blocks need consistent spacing */
|
||||
.block-wrapper + .block-wrapper .block.code,
|
||||
.block-wrapper + .block-wrapper .block.quote {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.drag-handle::before {
|
||||
content: "⋮⋮";
|
||||
font-size: 12px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.block-wrapper:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
}
|
||||
|
||||
.block-wrapper.dragging {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
z-index: 2001;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Blocks that should move out of the way */
|
||||
.block-wrapper.move-down {
|
||||
transform: translateY(var(--drag-offset, 0px));
|
||||
}
|
||||
|
||||
.block-wrapper.move-up {
|
||||
transform: translateY(calc(-1 * var(--drag-offset, 0px)));
|
||||
}
|
||||
|
||||
/* Drop indicator */
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.08)', 'rgba(77, 148, 255, 0.08)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
border-radius: 8px;
|
||||
transition: top 0.2s ease, height 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1999;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Remove old drag-over styles */
|
||||
.block-wrapper.drag-over-before,
|
||||
.block-wrapper.drag-over-after {
|
||||
/* No longer needed, using drop indicator instead */
|
||||
}
|
||||
|
||||
.editor-content.dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Block Settings Button */
|
||||
.block-settings {
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.block-wrapper:hover .block-settings {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.block-settings:hover {
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
}
|
||||
|
||||
.block-settings:active {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
}
|
||||
|
||||
/* Text Selection Styles */
|
||||
.block ::selection {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Formatting Menu */
|
||||
.formatting-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 1001;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.format-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.format-button.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-button.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-button.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-button .code-icon {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Applied format styles in content */
|
||||
.block strong,
|
||||
.block b {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block em,
|
||||
.block i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.block u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.block strike,
|
||||
.block s {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block code {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: ${cssManager.bdTheme('#d14', '#ff6b6b')};
|
||||
}
|
||||
|
||||
.block a {
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.block a:hover {
|
||||
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
`;
|
||||
20
ts_web/elements/wysiwyg/wysiwyg.types.ts
Normal file
20
ts_web/elements/wysiwyg/wysiwyg.types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface IBlock {
|
||||
id: string;
|
||||
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider' | 'youtube' | 'markdown' | 'html' | 'attachment';
|
||||
content: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ISlashMenuItem {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface IShortcutPattern {
|
||||
pattern: RegExp;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type OutputFormat = 'html' | 'markdown';
|
||||
Reference in New Issue
Block a user