Compare commits

...

47 Commits

Author SHA1 Message Date
113c013ea9 1.9.2
Some checks failed
Default (tags) / security (push) Failing after 26s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 23:57:32 +00:00
0571d5bf4b fi(wysiwyg): fix navigation 2025-06-24 23:56:40 +00:00
5f86fdba72 update 2025-06-24 23:46:52 +00:00
474385a939 update 2025-06-24 23:15:56 +00:00
71d64fccb8 1.9.1
Some checks failed
Default (tags) / security (push) Failing after 28s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-24 22:46:24 +00:00
e9541da8ff refactor 2025-06-24 22:45:50 +00:00
68b4e9ec8e feat(wysiwyg): Add more block types 2025-06-24 20:32:03 +00:00
856d354b5a update 2025-06-24 18:52:48 +00:00
89a4a15e78 update 2025-06-24 18:43:51 +00:00
fca3638f7f implement image upload 2025-06-24 17:16:13 +00:00
90fc8bed35 update selection reversal 2025-06-24 17:09:19 +00:00
bd223f77d0 selection manipulation 2025-06-24 16:53:54 +00:00
1041814823 update 2025-06-24 16:49:40 +00:00
366544befc fix(wysiwyg): Improve text selection detection with block-level approach
- Remove global selectionchange listener in favor of block-level detection
- Add comprehensive debugging logs to track selection detection
- Add multiple event listeners (mouseup, keyup, selectstart) for better coverage
- Add debounced selection checking to avoid race conditions
- Add click-outside handler to hide formatting menu
- Simplify selection detection logic by removing complex shadow DOM traversal
2025-06-24 16:31:00 +00:00
3b93bd63a7 fix(wysiwyg): Fix text selection detection for formatting menu in Shadow DOM
- Update selection detection to properly handle Shadow DOM boundaries
- Use getComposedRanges API correctly according to MDN documentation
- Add direct selection detection within block components
- Dispatch custom events from blocks when text is selected
- Fix formatting menu positioning using selection rect from events
2025-06-24 16:17:00 +00:00
ca525ce7e3 feat(wysiwyg): implement backspace 2025-06-24 15:52:28 +00:00
83f153f654 update 2025-06-24 15:17:37 +00:00
75637c7793 fix(wysiwyg): fix demo 2025-06-24 15:15:46 +00:00
e0a125c9bd fix(wysiwyg): cursor position 2025-06-24 13:53:47 +00:00
4b2178cedd fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 13:41:12 +00:00
08a4c361fa fix(wysiwyg):Improve Wysiwyg editor 2025-06-24 12:24:02 +00:00
35a648d450 refactor(wysiwyg): Clean up code and ensure drag-drop works with programmatic rendering
- Update drag handler to work without requestUpdate calls
- Remove duplicate modal methods (using WysiwygModalManager)
- Clean up unused imports and methods
- Ensure all DOM updates are programmatic
- Add comprehensive test files for all features
- Follow separation of concerns principles
2025-06-24 11:12:56 +00:00
1c76ade150 fix(wysiwyg): Implement programmatic rendering to eliminate focus loss during typing
- Convert parent component to use static rendering with programmatic DOM manipulation
- Remove all reactive state that could trigger re-renders during editing
- Delay content sync to avoid interference with typing (2s auto-save)
- Update all block operations to use manual DOM updates instead of Lit re-renders
- Fix specific issue where typing + arrow keys caused focus loss
- Add comprehensive focus management documentation
2025-06-24 11:06:02 +00:00
8b02c5aea3 fix(dees-modal): theming 2025-06-24 10:45:06 +00:00
c82c407350 fix(dees-moadl): theming 2025-06-24 08:23:24 +00:00
169f74aa2e Improve Wysiwyg editor 2025-06-24 08:19:53 +00:00
e4a042907a Improve Wysiwyg editor 2025-06-24 07:21:09 +00:00
7ce282c500 feat(wysiwyg): Add language selection for code blocks, block settings menu, fix cursor navigation, and update demo to use dees-panel
- Added modal to select programming language when creating code blocks
- Added settings button (3 dots) on each block for configuration
- Fixed cursor not jumping to new block after pressing Enter
- Special handling for code blocks: Enter creates new line, Shift+Enter creates new block
- Display language indicator on code blocks
- Updated demo to use dees-panel instead of demo-section divs
- Added language selection in block settings modal for existing code blocks
2025-06-23 21:28:58 +00:00
302777feff feat(editor): Add wysiwyg editor 2025-06-23 21:18:03 +00:00
cdcd4f79c8 feat(editor): Add wysiwyg editor 2025-06-23 21:15:04 +00:00
f2e6342a61 feat(editor): Add wysiwyg editor 2025-06-23 18:12:24 +00:00
0f02e7d00f feat(editor): Add wysiwyg editor 2025-06-23 18:07:46 +00:00
a1079cbbdd feat(editor): Add wysiwyg editor 2025-06-23 18:02:40 +00:00
58af08cb0d feat(editor): Add wysiwyg editor 2025-06-23 17:52:10 +00:00
6626726029 feat(editor): Add wysiwyg editor 2025-06-23 17:36:39 +00:00
f3afbb2e48 feat(editor): Add wysiwyg editor 2025-06-23 17:14:47 +00:00
fbd52ee9a5 feat(editor): Add wysiwyg editor 2025-06-23 17:09:53 +00:00
3284d91c2a feat(editor): Add wysiwyg editor 2025-06-23 17:05:28 +00:00
22e6b74c4f 1.9.0
Some checks failed
Default (tags) / security (push) Failing after 27s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-22 20:32:59 +00:00
4de835474b feat(form-inputs): Improve form input consistency and auto spacing across inputs and buttons 2025-06-22 20:32:59 +00:00
024d8af40d 1.8.20
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 21s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-20 00:11:30 +00:00
808b74fa17 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 2025-06-20 00:11:30 +00:00
202881ef1a 1.8.19
Some checks failed
Default (tags) / security (push) Failing after 42s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 14:01:14 +00:00
7de3d451ad fix: Change import to type for DeesForm in dees-form-submit 2025-06-19 14:01:07 +00:00
f0e0430016 1.8.18
Some checks failed
Default (tags) / security (push) Failing after 44s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 13:48:18 +00:00
873579fc97 fix: Import dees-button in dees-form-submit for button functionality 2025-06-19 13:47:52 +00:00
d321db363d 1.8.17
Some checks failed
Default (tags) / security (push) Failing after 45s
Default (tags) / test (push) Failing after 37s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-19 12:54:51 +00:00
68 changed files with 15318 additions and 577 deletions

View File

@@ -1,5 +1,22 @@
# Changelog # 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) ## 2025-06-10 - 1.8.1 - fix(dees-statsgrid)
Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles. Adjust stats grid styling for better alignment and improved visualizations in gauge and trend tiles.

View File

@@ -1,13 +1,13 @@
{ {
"name": "@design.estate/dees-catalog", "name": "@design.estate/dees-catalog",
"version": "1.8.16", "version": "1.9.2",
"private": false, "private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.", "description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js", "main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts", "typings": "dist_ts_web/index.d.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "tstest test/ --web", "test": "tstest test/ --web --verbose --timeout 30",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production", "build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production",
"watch": "tswatch element", "watch": "tswatch element",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
@@ -15,8 +15,8 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@design.estate/dees-domtools": "^2.1.1", "@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.0.42", "@design.estate/dees-element": "^2.0.45",
"@design.estate/dees-wcctools": "^1.0.98", "@design.estate/dees-wcctools": "^1.0.98",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2",
@@ -30,7 +30,7 @@
"apexcharts": "^4.7.0", "apexcharts": "^4.7.0",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"ibantools": "^4.5.1", "ibantools": "^4.5.1",
"lucide": "^0.515.0", "lucide": "^0.522.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"xterm": "^5.3.0", "xterm": "^5.3.0",
@@ -38,7 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.4", "@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/tstest": "^2.3.1",
"@git.zone/tswatch": "^2.0.37", "@git.zone/tswatch": "^2.0.37",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.0.2",

860
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -59,3 +59,458 @@
- Action grouping - Action grouping
- Navigation options - Navigation options
- Filter controls - 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

Binary file not shown.

View 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
View 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

View File

@@ -1,6 +1,6 @@
import { tap, expect, webhelpers } from '@push.rocks/tapbundle'; import { tap, expect, webhelpers } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web'; import * as deesCatalog from '../ts_web/index.js';
tap.test('should create a working button', async () => { tap.test('should create a working button', async () => {
const button: deesCatalog.DeesButton = await webhelpers.fixture( const button: deesCatalog.DeesButton = await webhelpers.fixture(
@@ -9,4 +9,4 @@ tap.test('should create a working button', async () => {
expect(button).toBeInstanceOf(deesCatalog.DeesButton); expect(button).toBeInstanceOf(deesCatalog.DeesButton);
}); });
tap.start(); export default tap.start();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', 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.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

View File

@@ -1,6 +1,42 @@
import { html } from '@design.estate/dees-element'; import { html, css } from '@design.estate/dees-element';
export const demoFunc = () => html` 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> <dees-button>This is a slotted Text</dees-button>
<p> <p>
<dees-button text="Highlighted: This text shows" type="highlighted">Highlighted</dees-button> <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 type="discreet">This is discreete button</dees-button></p>
<p><dees-button disabled>This is a disabled 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> <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 status="normal">Normal Status</dees-button></p>
<p><dees-button disabled status="pending">Pending 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="success">Success Status</dees-button></p>
<p><dees-button disabled status="error">Error 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>
`; `;

View File

@@ -55,10 +55,24 @@ export class DeesButton extends DeesElement {
}) })
public status: 'normal' | 'pending' | 'success' | 'error' = 'normal'; public status: 'normal' | 'pending' | 'success' | 'error' = 'normal';
@property({
type: Boolean,
reflect: true
})
public insideForm: boolean = false;
constructor() { constructor() {
super(); 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 = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
css` css`
@@ -71,6 +85,27 @@ export class DeesButton extends DeesElement {
display: none; 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 { .button {
transition: all 0.1s , color 0s; transition: all 0.1s , color 0s;
position: relative; position: relative;

View File

@@ -0,0 +1,3 @@
import { html } from '@design.estate/dees-element';
export const demoFunc = () => html`<dees-form-submit>Submit Form</dees-form-submit>`;

View File

@@ -1,3 +1,4 @@
import { demoFunc } from './dees-form-submit.demo.js';
import { import {
customElement, customElement,
html, html,
@@ -6,7 +7,7 @@ import {
cssManager, cssManager,
property, property,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { DeesForm } from './dees-form.js'; import type { DeesForm } from './dees-form.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -16,7 +17,7 @@ declare global {
@customElement('dees-form-submit') @customElement('dees-form-submit')
export class DeesFormSubmit extends DeesElement { export class DeesFormSubmit extends DeesElement {
public static demo = () => html`<dees-form-submit>Submit Form</dees-form-submit>`; public static demo = demoFunc;
@property({ @property({
type: Boolean, type: Boolean,
@@ -57,13 +58,15 @@ export class DeesFormSubmit extends DeesElement {
return; return;
} }
const parentElement: DeesForm = this.parentElement as DeesForm; const parentElement: DeesForm = this.parentElement as DeesForm;
if (parentElement && parentElement.gatherAndDispatch) {
parentElement.gatherAndDispatch(); parentElement.gatherAndDispatch();
} }
}
public async focus() { public async focus() {
const domtools = await this.domtoolsPromise; const domtools = await this.domtoolsPromise;
if (!this.disabled) { if (!this.disabled) {
domtools.convenience.smartdelay.delayFor(0); await domtools.convenience.smartdelay.delayFor(0);
this.submit(); this.submit();
} }
} }

View File

@@ -15,68 +15,18 @@ export const demoFunc = () => html`
margin: 0 auto; margin: 0 auto;
} }
.demo-section { dees-panel {
background: #f8f9fa; margin-bottom: 24px;
border-radius: 8px;
padding: 24px;
} }
@media (prefers-color-scheme: dark) { dees-panel:last-child {
.demo-section { margin-bottom: 0;
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;
} }
`} `}
</style> </style>
<div class="demo-container"> <div class="demo-container">
<div class="demo-section"> <dees-panel .heading="Complete Form Example" .description="A comprehensive form with various input types, validation, and form submission handling">
<h3>Complete Form Example</h3>
<p>A comprehensive form with various input types, validation, and form submission handling</p>
<div class="form-container">
<dees-form <dees-form
@formData=${async (eventArg) => { @formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget; const form: DeesForm = eventArg.currentTarget;
@@ -142,14 +92,9 @@ export const demoFunc = () => html`
<dees-form-submit>Create Account</dees-form-submit> <dees-form-submit>Create Account</dees-form-submit>
</dees-form> </dees-form>
</div> </dees-panel>
</div>
<div class="demo-section"> <dees-panel .heading="Horizontal Form Layout" .description="Compact form with inputs arranged horizontally - perfect for filters and quick forms">
<h3>Horizontal Form Layout</h3>
<p>Compact form with inputs arranged horizontally - perfect for filters and quick forms</p>
<div class="form-container">
<dees-form horizontal-layout> <dees-form horizontal-layout>
<dees-input-text <dees-input-text
key="search" key="search"
@@ -186,14 +131,9 @@ export const demoFunc = () => html`
.value=${true} .value=${true}
></dees-input-checkbox> ></dees-input-checkbox>
</dees-form> </dees-form>
</div> </dees-panel>
</div>
<div class="demo-section"> <dees-panel .heading="Advanced Form Features" .description="Form with specialized input types and complex validation">
<h3>Advanced Form Features</h3>
<p>Form with specialized input types and complex validation</p>
<div class="form-container">
<dees-form <dees-form
@formData=${async (eventArg) => { @formData=${async (eventArg) => {
const form: DeesForm = eventArg.currentTarget; const form: DeesForm = eventArg.currentTarget;
@@ -241,8 +181,7 @@ export const demoFunc = () => html`
<dees-form-submit>Submit Application</dees-form-submit> <dees-form-submit>Submit Application</dees-form-submit>
</dees-form> </dees-form>
</div> </dees-panel>
</div>
</div> </div>
</dees-demowrapper> </dees-demowrapper>
`; `;

View File

@@ -132,12 +132,31 @@ export class DeesForm extends DeesElement {
public async collectFormData() { public async collectFormData() {
const children = this.getFormElements(); const children = this.getFormElements();
const valueObject: { [key: string]: string | number | boolean | any[] | File[] | { option: string; key: string; payload?: any } } = {}; 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) { for (const child of children) {
if (!child.key) { if (!child.key) {
console.log(`form element with label "${child.label}" has no key. skipping.`); 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; 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; return valueObject;
} }

View File

@@ -3,33 +3,7 @@ import '@design.estate/dees-wcctools/demotools';
import type { DeesInputRadio } from './dees-input-radio.js'; import type { DeesInputRadio } from './dees-input-radio.js';
export const demoFunc = () => html` export const demoFunc = () => html`
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => { <dees-demowrapper>
// 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;
}
});
});
});
}}>
<style> <style>
${css` ${css`
.demo-container { .demo-container {
@@ -121,37 +95,43 @@ export const demoFunc = () => html`
<h3>Basic Radio Groups</h3> <h3>Basic Radio Groups</h3>
<p>Radio buttons for single-choice selections</p> <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> <div class="radio-group-title">Select your subscription plan:</div>
<dees-input-radio <dees-input-radio
.label=${'Basic Plan - $9/month'} .label=${'Basic Plan - $9/month'}
.value=${true} .value=${true}
.key=${'plan-basic'} .key=${'plan-basic'}
.name=${'plan'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Pro Plan - $29/month'} .label=${'Pro Plan - $29/month'}
.key=${'plan-pro'} .key=${'plan-pro'}
.name=${'plan'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Enterprise Plan - $99/month'} .label=${'Enterprise Plan - $99/month'}
.key=${'plan-enterprise'} .key=${'plan-enterprise'}
.name=${'plan'}
></dees-input-radio> ></dees-input-radio>
</div> </div>
<div class="radio-group" data-group="priority"> <div class="radio-group">
<div class="radio-group-title">Task Priority:</div> <div class="radio-group-title">Task Priority:</div>
<dees-input-radio <dees-input-radio
.label=${'High Priority'} .label=${'High Priority'}
.key=${'priority-high'} .key=${'priority-high'}
.name=${'priority'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Medium Priority'} .label=${'Medium Priority'}
.value=${true} .value=${true}
.key=${'priority-medium'} .key=${'priority-medium'}
.name=${'priority'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Low Priority'} .label=${'Low Priority'}
.key=${'priority-low'} .key=${'priority-low'}
.name=${'priority'}
></dees-input-radio> ></dees-input-radio>
</div> </div>
</div> </div>
@@ -160,43 +140,49 @@ export const demoFunc = () => html`
<h3>Horizontal Layout</h3> <h3>Horizontal Layout</h3>
<p>Radio buttons arranged horizontally for yes/no questions</p> <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> <div style="margin-right: 16px;">Do you agree?</div>
<dees-input-radio <dees-input-radio
.label=${'Yes'} .label=${'Yes'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.value=${true} .value=${true}
.key=${'agree-yes'} .key=${'agree-yes'}
.name=${'agreement'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'No'} .label=${'No'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.key=${'agree-no'} .key=${'agree-no'}
.name=${'agreement'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Maybe'} .label=${'Maybe'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.key=${'agree-maybe'} .key=${'agree-maybe'}
.name=${'agreement'}
></dees-input-radio> ></dees-input-radio>
</div> </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> <div style="margin-right: 16px;">Experience Level:</div>
<dees-input-radio <dees-input-radio
.label=${'Beginner'} .label=${'Beginner'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.key=${'exp-beginner'} .key=${'exp-beginner'}
.name=${'experience'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Intermediate'} .label=${'Intermediate'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.value=${true} .value=${true}
.key=${'exp-intermediate'} .key=${'exp-intermediate'}
.name=${'experience'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Expert'} .label=${'Expert'}
.layoutMode=${'horizontal'} .layoutMode=${'horizontal'}
.key=${'exp-expert'} .key=${'exp-expert'}
.name=${'experience'}
></dees-input-radio> ></dees-input-radio>
</div> </div>
</div> </div>
@@ -206,22 +192,22 @@ export const demoFunc = () => html`
<p>Multiple radio groups in a survey format</p> <p>Multiple radio groups in a survey format</p>
<div class="grid-layout"> <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> <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=${'Very Satisfied'} .key=${'sat-very'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Satisfied'} .value=${true} .key=${'sat-normal'}></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'}></dees-input-radio> <dees-input-radio .label=${'Neutral'} .key=${'sat-neutral'} .name=${'satisfaction'}></dees-input-radio>
<dees-input-radio .label=${'Dissatisfied'} .key=${'sat-dis'}></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'}></dees-input-radio> <dees-input-radio .label=${'Very Dissatisfied'} .key=${'sat-verydis'} .name=${'satisfaction'}></dees-input-radio>
</div> </div>
<div class="radio-group" data-group="recommend"> <div class="radio-group">
<div class="radio-group-title">Would you recommend us?</div> <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=${'Definitely'} .key=${'rec-def'} .name=${'recommend'}></dees-input-radio>
<dees-input-radio .label=${'Probably'} .value=${true} .key=${'rec-prob'}></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'}></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'}></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'}></dees-input-radio> <dees-input-radio .label=${'Definitely Not'} .key=${'rec-defnot'} .name=${'recommend'}></dees-input-radio>
</div> </div>
</div> </div>
</div> </div>
@@ -230,26 +216,30 @@ export const demoFunc = () => html`
<h3>States</h3> <h3>States</h3>
<p>Different radio button states</p> <p>Different radio button states</p>
<div class="radio-group" data-group="states"> <div class="radio-group">
<dees-input-radio <dees-input-radio
.label=${'Normal Radio'} .label=${'Normal Radio'}
.key=${'state-normal'} .key=${'state-normal'}
.name=${'states'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Selected Radio'} .label=${'Selected Radio'}
.value=${true} .value=${true}
.key=${'state-selected'} .key=${'state-selected'}
.name=${'states'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Disabled Unchecked'} .label=${'Disabled Unchecked'}
.disabled=${true} .disabled=${true}
.key=${'state-disabled1'} .key=${'state-disabled1'}
.name=${'states2'}
></dees-input-radio> ></dees-input-radio>
<dees-input-radio <dees-input-radio
.label=${'Disabled Checked'} .label=${'Disabled Checked'}
.disabled=${true} .disabled=${true}
.value=${true} .value=${true}
.key=${'state-disabled2'} .key=${'state-disabled2'}
.name=${'states2'}
></dees-input-radio> ></dees-input-radio>
</div> </div>
</div> </div>
@@ -258,18 +248,18 @@ export const demoFunc = () => html`
<h3>Settings Example</h3> <h3>Settings Example</h3>
<p>Common radio button patterns in settings</p> <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> <div class="radio-group-title">Theme Preference:</div>
<dees-input-radio .label=${'Light Theme'} .key=${'theme-light'}></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'}></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'}></dees-input-radio> <dees-input-radio .label=${'System Default'} .key=${'theme-system'} .name=${'theme'}></dees-input-radio>
</div> </div>
<div class="radio-group" data-group="notifications"> <div class="radio-group">
<div class="radio-group-title">Notification Frequency:</div> <div class="radio-group-title">Notification Frequency:</div>
<dees-input-radio .label=${'All Notifications'} .key=${'notif-all'}></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'}></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'}></dees-input-radio> <dees-input-radio .label=${'None'} .key=${'notif-none'} .name=${'notifications'}></dees-input-radio>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -17,6 +17,8 @@ export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
@property() @property()
public value: boolean = false; public value: boolean = false;
@property({ type: String })
public name: string = '';
constructor() { constructor() {
super(); super();
@@ -95,7 +97,27 @@ export class DeesInputRadio extends DeesInputBase<DeesInputRadio> {
} }
public async toggleSelected () { 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', { this.dispatchEvent(new CustomEvent('newValue', {
detail: this.value, detail: this.value,
bubbles: true bubbles: true

View File

@@ -100,7 +100,8 @@ export class DeesInputText extends DeesInputBase {
input:focus { input:focus {
outline: none; 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; cursor: text;
} }
@@ -117,6 +118,7 @@ export class DeesInputText extends DeesInputBase {
padding: 4px 0px; padding: 4px 0px;
width: 40px; width: 40px;
z-index: 3; z-index: 3;
text-align: center;
} }
.showPassword:hover { .showPassword:hover {
@@ -146,12 +148,14 @@ export class DeesInputText extends DeesInputBase {
letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'}; letter-spacing: ${this.isPasswordBool ? '1px' : 'normal'};
color: ${this.goBright ? '#333' : '#ccc'}; color: ${this.goBright ? '#333' : '#ccc'};
} }
${this.validationText ? css` ${this.validationText
? css`
.validationContainer { .validationContainer {
height: 22px; height: 22px;
opacity: 1; opacity: 1;
} }
` : css` `
: css`
.validationContainer { .validationContainer {
height: 4px; height: 4px;
padding: 2px !important; padding: 2px !important;
@@ -168,9 +172,7 @@ export class DeesInputText extends DeesInputBase {
@input="${this.updateValue}" @input="${this.updateValue}"
.disabled=${this.disabled} .disabled=${this.disabled}
/> />
<div class="validationContainer"> <div class="validationContainer">${this.validationText}</div>
${this.validationText}
</div>
${this.isPasswordBool ${this.isPasswordBool
? html` ? html`
<div class="showPassword" @click=${this.togglePasswordView}> <div class="showPassword" @click=${this.togglePasswordView}>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
// Re-export the modular component from the wysiwyg directory
export { DeesInputWysiwyg } from './wysiwyg/dees-input-wysiwyg.js';

View File

@@ -93,12 +93,12 @@ export class DeesModal extends DeesElement {
opacity: 0; opacity: 0;
width: 480px; width: 480px;
min-height: 120px; min-height: 120px;
background: #111; background: ${cssManager.bdTheme('#ffffff', '#111')};
border-radius: 8px; border-radius: 8px;
border: 1px solid #222; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
transition: all 0.2s; transition: all 0.2s;
overflow: hidden; 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 { .modal.show {
@@ -118,7 +118,7 @@ export class DeesModal extends DeesElement {
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
border-bottom: 1px solid #222; border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
} }
.modal .content { .modal .content {
@@ -127,7 +127,7 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons { .modal .bottomButtons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
border-top: 1px solid #222; border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
justify-content: flex-end; justify-content: flex-end;
} }
@@ -138,8 +138,10 @@ export class DeesModal extends DeesElement {
line-height: 16px; line-height: 16px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
cursor: default; cursor: pointer;
user-select: none; 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 { .modal .bottomButtons .bottomButton:first-child {
@@ -151,13 +153,26 @@ export class DeesModal extends DeesElement {
.modal .bottomButtons .bottomButton:hover { .modal .bottomButtons .bottomButton:hover {
background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)}; background: ${cssManager.bdTheme(colors.bright.blue, colors.dark.blue)};
color: #ffffff;
} }
.modal .bottomButtons .bottomButton:active { .modal .bottomButtons .bottomButton:active {
background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)}; background: ${cssManager.bdTheme(colors.bright.blueActive, colors.dark.blueActive)};
color: #ffffff;
} }
.modal .bottomButtons .bottomButton:last-child { .modal .bottomButtons .bottomButton:last-child {
border-right: none; 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="content">${this.content}</div>
<div class="bottomButtons"> <div class="bottomButtons">
${this.menuOptions.map( ${this.menuOptions.map(
(actionArg) => html` (actionArg, index) => html`
<div class="bottomButton" @click=${() => { <div class="bottomButton ${index === this.menuOptions.length - 1 ? 'primary' : ''} ${actionArg.name === 'OK' ? 'ok' : ''}" @click=${() => {
actionArg.action(this); actionArg.action(this);
}}>${actionArg.name}</div> }}>${actionArg.name}</div>
` `

View File

@@ -31,6 +31,7 @@ export * from './dees-input-fileupload.js';
export * from './dees-input-iban.js'; export * from './dees-input-iban.js';
export * from './dees-input-typelist.js'; export * from './dees-input-typelist.js';
export * from './dees-input-phone.js'; export * from './dees-input-phone.js';
export * from './dees-input-wysiwyg.js';
export * from './dees-progressbar.js'; export * from './dees-progressbar.js';
export * from './dees-input-quantityselector.js'; export * from './dees-input-quantityselector.js';
export * from './dees-input-radio.js'; export * from './dees-input-radio.js';

View 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

View 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.

View 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 '';
}
}

View 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());
}
}

View 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(' ');
};

View 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;
}
`;
}
}

View 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';

View 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)
};
}
}

View 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
};
}
}

View 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;
}
}

View 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
};
}
}

View 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
};
}
}

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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
}
}

File diff suppressed because it is too large Load Diff

View 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';

View 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.

View 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

View 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.

View 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;
}
}

View 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();

View 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);
}
}
}

View 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];

View 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 ? `![${altText}](${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 ![alt](url)
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;
}
}

View 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);
}
}

View 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;
}
}

View 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()
};
}
}

View 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);
}
}
}

View 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;
}

View 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;
}
}
}

View 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>
`;
}
}

View 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;
}
}

View 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';

View 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')};
}
`;

View 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';