2025-06-24 10:45:06 +00:00
|
|
|
|
import {
|
|
|
|
|
customElement,
|
|
|
|
|
property,
|
2025-06-24 13:41:12 +00:00
|
|
|
|
static as html,
|
2025-06-24 10:45:06 +00:00
|
|
|
|
DeesElement,
|
|
|
|
|
type TemplateResult,
|
|
|
|
|
cssManager,
|
|
|
|
|
css,
|
|
|
|
|
} from '@design.estate/dees-element';
|
|
|
|
|
|
|
|
|
|
import { type IBlock } from './wysiwyg.types.js';
|
|
|
|
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
2025-06-24 13:41:12 +00:00
|
|
|
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
2025-06-24 22:45:50 +00:00
|
|
|
|
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
|
|
|
|
|
import './wysiwyg.blockregistration.js';
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface HTMLElementTagNameMap {
|
|
|
|
|
'dees-wysiwyg-block': DeesWysiwygBlock;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@customElement('dees-wysiwyg-block')
|
|
|
|
|
export class DeesWysiwygBlock extends DeesElement {
|
2025-06-24 16:49:40 +00:00
|
|
|
|
async disconnectedCallback() {
|
|
|
|
|
await super.disconnectedCallback();
|
|
|
|
|
// Clean up selection handler
|
|
|
|
|
if ((this as any)._selectionHandler) {
|
|
|
|
|
document.removeEventListener('selectionchange', (this as any)._selectionHandler);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
@property({ type: Object })
|
|
|
|
|
public block: IBlock;
|
|
|
|
|
|
|
|
|
|
@property({ type: Boolean })
|
|
|
|
|
public isSelected: boolean = false;
|
|
|
|
|
|
|
|
|
|
@property({ type: Object })
|
2025-06-24 22:45:50 +00:00
|
|
|
|
public handlers: IBlockEventHandlers;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Reference to the editable block element
|
|
|
|
|
private blockElement: HTMLDivElement | null = null;
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
|
|
// Track if we've initialized the content
|
|
|
|
|
private contentInitialized: boolean = false;
|
2025-06-24 15:17:37 +00:00
|
|
|
|
|
|
|
|
|
// Track cursor position
|
|
|
|
|
private lastKnownCursorPosition: number = 0;
|
2025-06-24 16:49:40 +00:00
|
|
|
|
private lastSelectedText: string = '';
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
2025-06-26 11:41:58 +00:00
|
|
|
|
private handlerStylesInjected = false;
|
2025-06-24 22:45:50 +00:00
|
|
|
|
|
|
|
|
|
private injectHandlerStyles(): void {
|
2025-06-26 11:41:58 +00:00
|
|
|
|
// Only inject once per instance
|
|
|
|
|
if (this.handlerStylesInjected) return;
|
|
|
|
|
this.handlerStylesInjected = true;
|
2025-06-24 22:45:50 +00:00
|
|
|
|
|
|
|
|
|
// Get styles from all registered block handlers
|
|
|
|
|
let styles = '';
|
|
|
|
|
const blockTypes = BlockRegistry.getAllTypes();
|
|
|
|
|
for (const type of blockTypes) {
|
|
|
|
|
const handler = BlockRegistry.getHandler(type);
|
|
|
|
|
if (handler) {
|
|
|
|
|
styles += handler.getStyles();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (styles) {
|
|
|
|
|
// Create and inject style element
|
|
|
|
|
const styleElement = document.createElement('style');
|
|
|
|
|
styleElement.textContent = styles;
|
|
|
|
|
this.shadowRoot?.appendChild(styleElement);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
|
public static styles = [
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
css`
|
|
|
|
|
:host {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block {
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
min-height: 1.6em;
|
|
|
|
|
outline: none;
|
|
|
|
|
width: 100%;
|
|
|
|
|
word-wrap: break-word;
|
|
|
|
|
position: relative;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block:empty:not(:focus)::before {
|
|
|
|
|
content: attr(data-placeholder);
|
|
|
|
|
color: ${cssManager.bdTheme('#999', '#666')};
|
|
|
|
|
position: absolute;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-26 11:41:58 +00:00
|
|
|
|
/* Code block styles moved to handler */
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
.block.list {
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.list ul,
|
|
|
|
|
.block.list ol {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding-left: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.list li {
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Formatting styles */
|
|
|
|
|
.block :is(b, strong) {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block :is(i, em) {
|
|
|
|
|
font-style: italic;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block u {
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block s {
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block code {
|
|
|
|
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
|
|
|
|
font-size: 0.9em;
|
|
|
|
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')};
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
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;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block a:hover {
|
|
|
|
|
border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-26 11:41:58 +00:00
|
|
|
|
/* Code block container and language styles moved to handler */
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
/* Selection styles */
|
|
|
|
|
.block ::selection {
|
|
|
|
|
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
|
|
|
|
color: inherit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Paragraph specific styles */
|
|
|
|
|
.block.paragraph {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Strike through */
|
|
|
|
|
.block :is(s, strike) {
|
|
|
|
|
text-decoration: line-through;
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* List specific margin adjustments */
|
|
|
|
|
.block.list li {
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.list li:last-child {
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Block margin adjustments based on type */
|
|
|
|
|
:host-context(.block-wrapper:first-child) .block {
|
|
|
|
|
margin-top: 0 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:host-context(.block-wrapper:last-child) .block {
|
|
|
|
|
margin-bottom: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Selected state */
|
|
|
|
|
.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;
|
|
|
|
|
}
|
2025-06-24 17:16:13 +00:00
|
|
|
|
|
|
|
|
|
/* Image block styles */
|
|
|
|
|
.block.image {
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
2025-06-24 18:43:51 +00:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.image:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.image.selected {
|
|
|
|
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
2025-06-24 17:16:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-upload-placeholder {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 200px;
|
|
|
|
|
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
|
|
|
|
|
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-upload-placeholder:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#f0f0f0', '#222222')};
|
|
|
|
|
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-upload-placeholder:active {
|
|
|
|
|
transform: scale(0.98);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-upload-placeholder.drag-over {
|
|
|
|
|
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
|
|
|
|
|
border-color: ${cssManager.bdTheme('#2196F3', '#64b5f6')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.upload-icon {
|
|
|
|
|
font-size: 48px;
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.upload-text {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: ${cssManager.bdTheme('#666', '#999')};
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.upload-hint {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: ${cssManager.bdTheme('#999', '#666')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-container {
|
|
|
|
|
width: 100%;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-container img {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: auto;
|
|
|
|
|
display: block;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-loading {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 50%;
|
|
|
|
|
left: 50%;
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
padding: 16px 24px;
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
color: white;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
input[type="file"] {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
2025-06-24 20:32:03 +00:00
|
|
|
|
|
|
|
|
|
/* YouTube block styles */
|
|
|
|
|
.block.youtube {
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
position: relative;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.youtube:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.youtube.selected {
|
|
|
|
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
|
|
|
|
height: 0;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-container iframe {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
border: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-placeholder {
|
|
|
|
|
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
|
|
|
|
|
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 40px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.placeholder-icon {
|
|
|
|
|
font-size: 48px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.placeholder-text {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: ${cssManager.bdTheme('#666', '#999')};
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-url-input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
|
|
|
color: ${cssManager.bdTheme('#000', '#fff')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-embed-btn {
|
|
|
|
|
padding: 10px 24px;
|
|
|
|
|
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
color: white;
|
|
|
|
|
border: none;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.youtube-embed-btn:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#0052a3', '#3d7dd9')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Markdown block styles */
|
|
|
|
|
.block.markdown {
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.markdown:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.markdown.selected {
|
|
|
|
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-toolbar,
|
|
|
|
|
.html-toolbar {
|
|
|
|
|
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-label,
|
|
|
|
|
.html-label {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: ${cssManager.bdTheme('#666', '#999')};
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toggle-preview {
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
background: ${cssManager.bdTheme('#fff', '#333')};
|
|
|
|
|
border: 1px solid ${cssManager.bdTheme('#ddd', '#555')};
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toggle-preview:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#f0f0f0', '#444')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-content,
|
|
|
|
|
.html-content {
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-editor,
|
|
|
|
|
.html-editor {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border: none;
|
|
|
|
|
resize: vertical;
|
|
|
|
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
|
|
|
|
|
color: ${cssManager.bdTheme('#000', '#fff')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-preview,
|
|
|
|
|
.html-preview {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
background: ${cssManager.bdTheme('#fff', '#1a1a1a')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-preview h1,
|
|
|
|
|
.markdown-preview h2,
|
|
|
|
|
.markdown-preview h3 {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.markdown-preview h1:first-child,
|
|
|
|
|
.markdown-preview h2:first-child,
|
|
|
|
|
.markdown-preview h3:first-child {
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* HTML block styles */
|
|
|
|
|
.block.html {
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.html:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.html.selected {
|
|
|
|
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Attachment block styles */
|
|
|
|
|
.block.attachment {
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.15s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.attachment:focus {
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.attachment.selected {
|
|
|
|
|
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.block.attachment.drag-over {
|
|
|
|
|
background: ${cssManager.bdTheme('#e3f2fd', '#1e3a5f')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-header {
|
|
|
|
|
background: ${cssManager.bdTheme('#f5f5f5', '#2a2a2a')};
|
|
|
|
|
padding: 16px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-icon {
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-title {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-list {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
min-height: 100px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-placeholder {
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 40px;
|
|
|
|
|
border: 2px dashed ${cssManager.bdTheme('#d0d0d0', '#404040')};
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-placeholder:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
|
|
|
|
|
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.placeholder-hint {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: ${cssManager.bdTheme('#999', '#666')};
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
background: ${cssManager.bdTheme('#f8f8f8', '#222')};
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
transition: background 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-item:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#f0f0f0', '#2a2a2a')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.file-icon {
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.file-info {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.file-name {
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.file-size {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: ${cssManager.bdTheme('#666', '#999')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.remove-file {
|
|
|
|
|
width: 24px;
|
|
|
|
|
height: 24px;
|
|
|
|
|
border: none;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: ${cssManager.bdTheme('#999', '#666')};
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.attachment-item:hover .remove-file {
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.remove-file:hover {
|
|
|
|
|
color: ${cssManager.bdTheme('#d32f2f', '#f44336')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.add-more-files {
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 10px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
border: 1px dashed ${cssManager.bdTheme('#ddd', '#444')};
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
color: ${cssManager.bdTheme('#666', '#999')};
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.add-more-files:hover {
|
|
|
|
|
background: ${cssManager.bdTheme('#f8f8f8', '#1a1a1a')};
|
|
|
|
|
border-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
`,
|
|
|
|
|
];
|
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
|
protected shouldUpdate(changedProperties: Map<string, any>): boolean {
|
2025-06-24 23:15:56 +00:00
|
|
|
|
// If selection state changed, update the selected class without re-rendering
|
|
|
|
|
if (changedProperties.has('isSelected') && this.block) {
|
|
|
|
|
// Find the block element based on block type
|
|
|
|
|
let element: HTMLElement | null = null;
|
|
|
|
|
|
|
|
|
|
// Build the specific selector based on block type
|
|
|
|
|
const blockType = this.block.type;
|
|
|
|
|
const selector = `.block.${blockType}`;
|
|
|
|
|
|
|
|
|
|
element = this.shadowRoot?.querySelector(selector) as HTMLElement;
|
|
|
|
|
|
2025-06-24 18:43:51 +00:00
|
|
|
|
if (element) {
|
|
|
|
|
if (this.isSelected) {
|
|
|
|
|
element.classList.add('selected');
|
|
|
|
|
} else {
|
|
|
|
|
element.classList.remove('selected');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false; // Don't re-render, just update the class
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
|
// Never update if only the block content changed
|
|
|
|
|
if (changedProperties.has('block') && this.block) {
|
|
|
|
|
const oldBlock = changedProperties.get('block');
|
2025-06-24 22:45:50 +00:00
|
|
|
|
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
|
2025-06-24 11:06:02 +00:00
|
|
|
|
// Only content or metadata changed, don't re-render
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
|
// Only update if the block type or id changes
|
|
|
|
|
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
|
|
|
|
|
}
|
2025-06-24 11:06:02 +00:00
|
|
|
|
|
|
|
|
|
public firstUpdated(): void {
|
|
|
|
|
// Mark that content has been initialized
|
|
|
|
|
this.contentInitialized = true;
|
|
|
|
|
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Inject handler styles if not already done
|
|
|
|
|
this.injectHandlerStyles();
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// First, populate the container with the rendered content
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container && this.block) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
if (this.block) {
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler) {
|
|
|
|
|
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
if (blockElement) {
|
|
|
|
|
handler.setup(blockElement, this.block, this.handlers);
|
|
|
|
|
}
|
|
|
|
|
return; // Block handler takes care of all setup
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 18:43:51 +00:00
|
|
|
|
// Handle special block types
|
2025-06-24 17:16:13 +00:00
|
|
|
|
if (this.block.type === 'image') {
|
|
|
|
|
this.setupImageBlock();
|
|
|
|
|
return; // Image blocks don't need the standard editable setup
|
2025-06-24 20:32:03 +00:00
|
|
|
|
} else if (this.block.type === 'youtube') {
|
|
|
|
|
this.setupYouTubeBlock();
|
|
|
|
|
return;
|
|
|
|
|
} else if (this.block.type === 'markdown') {
|
|
|
|
|
this.setupMarkdownBlock();
|
|
|
|
|
return;
|
|
|
|
|
} else if (this.block.type === 'html') {
|
|
|
|
|
this.setupHtmlBlock();
|
|
|
|
|
return;
|
|
|
|
|
} else if (this.block.type === 'attachment') {
|
|
|
|
|
this.setupAttachmentBlock();
|
|
|
|
|
return;
|
2025-06-24 17:16:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Now find the actual editable block element
|
2025-06-24 13:41:12 +00:00
|
|
|
|
const editableBlock = this.block.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 15:17:37 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:41:12 +00:00
|
|
|
|
|
2025-06-24 11:06:02 +00:00
|
|
|
|
// Ensure the block element maintains its content
|
2025-06-24 13:41:12 +00:00
|
|
|
|
if (editableBlock) {
|
|
|
|
|
editableBlock.setAttribute('data-block-id', this.block.id);
|
|
|
|
|
editableBlock.setAttribute('data-block-type', this.block.type);
|
|
|
|
|
|
|
|
|
|
// Set up all event handlers manually to avoid Lit re-renders
|
|
|
|
|
editableBlock.addEventListener('input', (e) => {
|
|
|
|
|
this.handlers?.onInput?.(e as InputEvent);
|
2025-06-24 15:17:37 +00:00
|
|
|
|
|
|
|
|
|
// Track cursor position after input
|
|
|
|
|
const pos = this.getCursorPosition(editableBlock);
|
|
|
|
|
if (pos !== null) {
|
|
|
|
|
this.lastKnownCursorPosition = pos;
|
|
|
|
|
}
|
2025-06-24 13:41:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('keydown', (e) => {
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Track cursor position before keydown
|
|
|
|
|
const pos = this.getCursorPosition(editableBlock);
|
|
|
|
|
if (pos !== null) {
|
|
|
|
|
this.lastKnownCursorPosition = pos;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:41:12 +00:00
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('focus', () => {
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('blur', () => {
|
|
|
|
|
this.handlers?.onBlur?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('compositionstart', () => {
|
|
|
|
|
this.handlers?.onCompositionStart?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('compositionend', () => {
|
|
|
|
|
this.handlers?.onCompositionEnd?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editableBlock.addEventListener('mouseup', (e) => {
|
2025-06-24 16:49:40 +00:00
|
|
|
|
const pos = this.getCursorPosition(editableBlock);
|
|
|
|
|
if (pos !== null) {
|
|
|
|
|
this.lastKnownCursorPosition = pos;
|
|
|
|
|
}
|
2025-06-24 15:17:37 +00:00
|
|
|
|
|
2025-06-24 16:49:40 +00:00
|
|
|
|
// Selection will be handled by selectionchange event
|
2025-06-24 13:41:12 +00:00
|
|
|
|
this.handlers?.onMouseUp?.(e);
|
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
editableBlock.addEventListener('click', (e: MouseEvent) => {
|
|
|
|
|
// Small delay to let browser set cursor position
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const pos = this.getCursorPosition(editableBlock);
|
|
|
|
|
if (pos !== null) {
|
|
|
|
|
this.lastKnownCursorPosition = pos;
|
|
|
|
|
}
|
|
|
|
|
}, 0);
|
2025-06-24 13:41:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 16:49:40 +00:00
|
|
|
|
// 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.dispatchEvent(new CustomEvent('block-text-selected', {
|
|
|
|
|
detail: {
|
|
|
|
|
text: '',
|
|
|
|
|
blockId: this.block.id,
|
|
|
|
|
hasSelection: false
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get fresh reference to the editable block
|
|
|
|
|
const currentEditableBlock = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
|
|
|
|
|
|
|
|
|
if (!currentEditableBlock) return;
|
|
|
|
|
|
|
|
|
|
// Get parent wysiwyg component's shadow root
|
|
|
|
|
const parentComponent = this.closest('dees-input-wysiwyg');
|
|
|
|
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
|
|
|
|
|
|
|
|
|
// Use getComposedRanges with shadow roots as per MDN docs
|
|
|
|
|
const shadowRoots: ShadowRoot[] = [];
|
|
|
|
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
|
|
|
|
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
|
|
|
|
|
|
|
|
|
|
// Get selection info using our Shadow DOM-aware utility
|
|
|
|
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
|
|
|
|
if (!selectionInfo) return;
|
|
|
|
|
|
|
|
|
|
// Check if selection is within this block
|
2025-06-24 22:45:50 +00:00
|
|
|
|
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.startContainer);
|
|
|
|
|
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer);
|
2025-06-24 16:49:40 +00:00
|
|
|
|
|
|
|
|
|
if (startInBlock || endInBlock) {
|
|
|
|
|
if (selectedText !== this.lastSelectedText) {
|
|
|
|
|
this.lastSelectedText = selectedText;
|
|
|
|
|
|
|
|
|
|
// Create range and get rect
|
|
|
|
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
|
|
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
// Dispatch event
|
|
|
|
|
this.dispatchEvent(new CustomEvent('block-text-selected', {
|
|
|
|
|
detail: {
|
|
|
|
|
text: selectedText.trim(),
|
|
|
|
|
blockId: this.block.id,
|
|
|
|
|
range: range,
|
|
|
|
|
rect: rect,
|
|
|
|
|
hasSelection: true
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} else if (this.lastSelectedText) {
|
|
|
|
|
// Clear selection if no longer in this block
|
|
|
|
|
this.lastSelectedText = '';
|
|
|
|
|
this.dispatchEvent(new CustomEvent('block-text-selected', {
|
|
|
|
|
detail: {
|
|
|
|
|
text: '',
|
|
|
|
|
blockId: this.block.id,
|
|
|
|
|
hasSelection: false
|
|
|
|
|
},
|
|
|
|
|
bubbles: true,
|
|
|
|
|
composed: true
|
|
|
|
|
}));
|
|
|
|
|
}
|
2025-06-24 16:31:00 +00:00
|
|
|
|
};
|
|
|
|
|
|
2025-06-24 16:49:40 +00:00
|
|
|
|
// Listen for selection changes
|
|
|
|
|
document.addEventListener('selectionchange', checkSelection);
|
|
|
|
|
|
|
|
|
|
// Store the handler for cleanup
|
|
|
|
|
(this as any)._selectionHandler = checkSelection;
|
|
|
|
|
|
|
|
|
|
// Add keyup handler for cursor position tracking
|
|
|
|
|
editableBlock.addEventListener('keyup', (e) => {
|
|
|
|
|
// Track cursor position
|
|
|
|
|
const pos = this.getCursorPosition(editableBlock);
|
|
|
|
|
if (pos !== null) {
|
|
|
|
|
this.lastKnownCursorPosition = pos;
|
2025-06-24 16:31:00 +00:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 13:41:12 +00:00
|
|
|
|
// Set initial content if needed
|
|
|
|
|
if (this.block.content) {
|
|
|
|
|
if (this.block.type === 'code') {
|
|
|
|
|
editableBlock.textContent = this.block.content;
|
|
|
|
|
} else if (this.block.type === 'list') {
|
|
|
|
|
editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
|
|
|
|
} else {
|
|
|
|
|
editableBlock.innerHTML = this.block.content;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Store reference to the block element for quick access
|
|
|
|
|
this.blockElement = editableBlock;
|
2025-06-24 11:06:02 +00:00
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
render(): TemplateResult {
|
|
|
|
|
if (!this.block) return html``;
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Since we need dynamic content, we'll render an empty container
|
|
|
|
|
// and set the innerHTML in firstUpdated
|
|
|
|
|
return html`<div class="wysiwyg-block-container"></div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderBlockContent(): string {
|
|
|
|
|
if (!this.block) return '';
|
|
|
|
|
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler) {
|
|
|
|
|
return handler.render(this.block, this.isSelected);
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.block.type === 'code') {
|
|
|
|
|
const language = this.block.metadata?.language || 'plain text';
|
2025-06-24 15:17:37 +00:00
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
return `
|
2025-06-24 10:45:06 +00:00
|
|
|
|
<div class="code-block-container">
|
|
|
|
|
<div class="code-language">${language}</div>
|
|
|
|
|
<div
|
2025-06-24 15:17:37 +00:00
|
|
|
|
class="block code${selectedClass}"
|
2025-06-24 10:45:06 +00:00
|
|
|
|
contenteditable="true"
|
|
|
|
|
data-block-type="${this.block.type}"
|
|
|
|
|
></div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 17:16:13 +00:00
|
|
|
|
if (this.block.type === 'image') {
|
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
const imageUrl = this.block.metadata?.url || '';
|
|
|
|
|
const isLoading = this.block.metadata?.loading || false;
|
|
|
|
|
|
|
|
|
|
return `
|
2025-06-24 18:43:51 +00:00
|
|
|
|
<div class="block image${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
2025-06-24 17:16:13 +00:00
|
|
|
|
${isLoading ? `
|
|
|
|
|
<div class="image-loading">Uploading image...</div>
|
|
|
|
|
` : ''}
|
|
|
|
|
${imageUrl ? `
|
|
|
|
|
<div class="image-container">
|
|
|
|
|
<img src="${imageUrl}" alt="${this.block.content || 'Uploaded image'}" />
|
|
|
|
|
</div>
|
|
|
|
|
` : `
|
|
|
|
|
<div class="image-upload-placeholder">
|
|
|
|
|
<div class="upload-icon">🖼️</div>
|
|
|
|
|
<div class="upload-text">Click to upload an image</div>
|
|
|
|
|
<div class="upload-hint">or drag and drop</div>
|
|
|
|
|
</div>
|
|
|
|
|
<input type="file" accept="image/*" style="display: none;" />
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 20:32:03 +00:00
|
|
|
|
if (this.block.type === 'youtube') {
|
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
const videoId = this.block.metadata?.videoId || '';
|
|
|
|
|
const url = this.block.metadata?.url || '';
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="block youtube${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
|
|
|
|
${videoId ? `
|
|
|
|
|
<div class="youtube-container">
|
|
|
|
|
<iframe
|
|
|
|
|
src="https://www.youtube.com/embed/${videoId}"
|
|
|
|
|
frameborder="0"
|
|
|
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
|
|
|
allowfullscreen
|
|
|
|
|
></iframe>
|
|
|
|
|
</div>
|
|
|
|
|
` : `
|
|
|
|
|
<div class="youtube-placeholder">
|
|
|
|
|
<div class="placeholder-icon">▶️</div>
|
|
|
|
|
<div class="placeholder-text">Enter YouTube URL</div>
|
|
|
|
|
<input type="url" class="youtube-url-input" placeholder="https://youtube.com/watch?v=..." value="${url || ''}" />
|
|
|
|
|
<button class="youtube-embed-btn">Embed Video</button>
|
|
|
|
|
</div>
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.block.type === 'markdown') {
|
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
const showPreview = this.block.metadata?.showPreview !== false;
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="block markdown${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
|
|
|
|
<div class="markdown-toolbar">
|
|
|
|
|
<button class="toggle-preview" data-active="${showPreview}">
|
|
|
|
|
${showPreview ? 'Edit' : 'Preview'}
|
|
|
|
|
</button>
|
|
|
|
|
<span class="markdown-label">Markdown</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="markdown-content">
|
|
|
|
|
${showPreview ? `
|
|
|
|
|
<div class="markdown-preview"></div>
|
|
|
|
|
` : `
|
|
|
|
|
<textarea class="markdown-editor" placeholder="Write markdown here...">${this.block.content || ''}</textarea>
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.block.type === 'html') {
|
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
const showPreview = this.block.metadata?.showPreview !== false;
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="block html${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
|
|
|
|
<div class="html-toolbar">
|
|
|
|
|
<button class="toggle-preview" data-active="${showPreview}">
|
|
|
|
|
${showPreview ? 'Edit' : 'Preview'}
|
|
|
|
|
</button>
|
|
|
|
|
<span class="html-label">HTML</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="html-content">
|
|
|
|
|
${showPreview ? `
|
|
|
|
|
<div class="html-preview"></div>
|
|
|
|
|
` : `
|
|
|
|
|
<textarea class="html-editor" placeholder="Write HTML here...">${this.block.content || ''}</textarea>
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.block.type === 'attachment') {
|
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
const files = this.block.metadata?.files || [];
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
<div class="block attachment${selectedClass}" data-block-id="${this.block.id}" data-block-type="${this.block.type}" tabindex="0">
|
|
|
|
|
<div class="attachment-header">
|
|
|
|
|
<div class="attachment-icon">📎</div>
|
|
|
|
|
<div class="attachment-title">File Attachments</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="attachment-list">
|
|
|
|
|
${files.length > 0 ? files.map((file: any) => `
|
|
|
|
|
<div class="attachment-item">
|
|
|
|
|
<div class="file-icon">${this.getFileIcon(file.type)}</div>
|
|
|
|
|
<div class="file-info">
|
|
|
|
|
<div class="file-name">${file.name}</div>
|
|
|
|
|
<div class="file-size">${this.formatFileSize(file.size)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="remove-file" data-file-id="${file.id}">×</button>
|
|
|
|
|
</div>
|
|
|
|
|
`).join('') : `
|
|
|
|
|
<div class="attachment-placeholder">
|
|
|
|
|
<div class="placeholder-text">Click to add files</div>
|
|
|
|
|
<div class="placeholder-hint">or drag and drop</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}
|
|
|
|
|
</div>
|
|
|
|
|
<input type="file" multiple style="display: none;" />
|
|
|
|
|
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
|
const placeholder = this.getPlaceholder();
|
2025-06-24 15:17:37 +00:00
|
|
|
|
const selectedClass = this.isSelected ? ' selected' : '';
|
|
|
|
|
return `
|
2025-06-24 10:45:06 +00:00
|
|
|
|
<div
|
2025-06-24 15:17:37 +00:00
|
|
|
|
class="block ${this.block.type}${selectedClass}"
|
2025-06-24 10:45:06 +00:00
|
|
|
|
contenteditable="true"
|
|
|
|
|
data-placeholder="${placeholder}"
|
2025-06-24 13:41:12 +00:00
|
|
|
|
></div>
|
2025-06-24 10:45:06 +00:00
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getPlaceholder(): string {
|
|
|
|
|
switch (this.block.type) {
|
|
|
|
|
case 'paragraph':
|
|
|
|
|
return "Type '/' for commands...";
|
|
|
|
|
case 'heading-1':
|
|
|
|
|
return 'Heading 1';
|
|
|
|
|
case 'heading-2':
|
|
|
|
|
return 'Heading 2';
|
|
|
|
|
case 'heading-3':
|
|
|
|
|
return 'Heading 3';
|
|
|
|
|
case 'quote':
|
|
|
|
|
return 'Quote';
|
2025-06-24 17:16:13 +00:00
|
|
|
|
case 'image':
|
|
|
|
|
return 'Click to upload an image';
|
2025-06-24 10:45:06 +00:00
|
|
|
|
default:
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public focus(): void {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.focus) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.focus(container, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 18:43:51 +00:00
|
|
|
|
// Handle non-editable blocks
|
2025-06-24 20:32:03 +00:00
|
|
|
|
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
|
|
|
|
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
|
|
|
|
const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement;
|
|
|
|
|
if (blockElement) {
|
|
|
|
|
blockElement.focus();
|
2025-06-24 18:43:51 +00:00
|
|
|
|
}
|
|
|
|
|
return;
|
2025-06-24 17:16:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:53:47 +00:00
|
|
|
|
// Get the actual editable element (might be nested for code blocks)
|
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 15:17:37 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
|
|
|
|
|
if (!editableElement) return;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
// Ensure the element is focusable
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (!editableElement.hasAttribute('contenteditable')) {
|
|
|
|
|
editableElement.setAttribute('contenteditable', 'true');
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.focus();
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
// If focus failed, try again after a microtask
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
|
2025-06-24 10:45:06 +00:00
|
|
|
|
Promise.resolve().then(() => {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.focus();
|
2025-06-24 10:45:06 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.focusWithCursor) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.focusWithCursor(container, position, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 18:43:51 +00:00
|
|
|
|
// Non-editable blocks don't support cursor positioning
|
2025-06-24 20:32:03 +00:00
|
|
|
|
const nonEditableTypes = ['image', 'divider', 'youtube', 'markdown', 'html', 'attachment'];
|
|
|
|
|
if (this.block && nonEditableTypes.includes(this.block.type)) {
|
2025-06-24 17:16:13 +00:00
|
|
|
|
this.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:53:47 +00:00
|
|
|
|
// Get the actual editable element (might be nested for code blocks)
|
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 15:17:37 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
|
|
|
|
|
if (!editableElement) return;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
// Ensure element is focusable first
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (!editableElement.hasAttribute('contenteditable')) {
|
|
|
|
|
editableElement.setAttribute('contenteditable', 'true');
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Focus the element
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.focus();
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
// Set cursor position after focus is established
|
|
|
|
|
const setCursor = () => {
|
|
|
|
|
if (position === 'start') {
|
|
|
|
|
this.setCursorToStart();
|
|
|
|
|
} else if (position === 'end') {
|
|
|
|
|
this.setCursorToEnd();
|
|
|
|
|
} else if (typeof position === 'number') {
|
2025-06-24 13:41:12 +00:00
|
|
|
|
// Use the new selection utility to set cursor position
|
2025-06-24 13:53:47 +00:00
|
|
|
|
WysiwygSelection.setCursorPosition(editableElement, position);
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Ensure cursor is set after focus
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
|
2025-06-24 10:45:06 +00:00
|
|
|
|
setCursor();
|
|
|
|
|
} else {
|
|
|
|
|
// Wait for focus to be established
|
|
|
|
|
Promise.resolve().then(() => {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
|
2025-06-24 10:45:06 +00:00
|
|
|
|
setCursor();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get cursor position in the editable element
|
|
|
|
|
*/
|
2025-06-24 15:52:28 +00:00
|
|
|
|
public getCursorPosition(element: HTMLElement): number | null {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.getCursorPosition) {
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.getCursorPosition(element, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Get parent wysiwyg component's shadow root
|
|
|
|
|
const parentComponent = this.closest('dees-input-wysiwyg');
|
|
|
|
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
|
|
|
|
|
|
|
|
|
// Get selection info with both shadow roots for proper traversal
|
|
|
|
|
const shadowRoots: ShadowRoot[] = [];
|
|
|
|
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
|
|
|
|
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
|
|
|
|
|
|
|
|
|
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
|
|
|
|
console.log('getCursorPosition: Selection info from shadow DOMs:', {
|
|
|
|
|
selectionInfo,
|
|
|
|
|
shadowRootsCount: shadowRoots.length
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!selectionInfo) {
|
|
|
|
|
console.log('getCursorPosition: No selection found');
|
|
|
|
|
return null;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getCursorPosition: Range info:', {
|
|
|
|
|
startContainer: selectionInfo.startContainer,
|
|
|
|
|
startOffset: selectionInfo.startOffset,
|
|
|
|
|
collapsed: selectionInfo.collapsed,
|
|
|
|
|
startContainerText: selectionInfo.startContainer.textContent
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!element.contains(selectionInfo.startContainer)) {
|
|
|
|
|
console.log('getCursorPosition: Range not in element');
|
|
|
|
|
return null;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Create a range from start of element to cursor position
|
|
|
|
|
const preCaretRange = document.createRange();
|
|
|
|
|
preCaretRange.selectNodeContents(element);
|
|
|
|
|
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
|
|
|
|
|
|
|
|
|
// Get the text content length up to cursor
|
|
|
|
|
const position = preCaretRange.toString().length;
|
|
|
|
|
console.log('getCursorPosition: Calculated position:', {
|
|
|
|
|
position,
|
|
|
|
|
preCaretText: preCaretRange.toString(),
|
|
|
|
|
elementText: element.textContent,
|
|
|
|
|
elementTextLength: element.textContent?.length
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return position;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getContent(): string {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.getContent) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.getContent(container, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 17:16:13 +00:00
|
|
|
|
// Handle image blocks specially
|
|
|
|
|
if (this.block?.type === 'image') {
|
|
|
|
|
return this.block.content || ''; // Image blocks store alt text in content
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:53:47 +00:00
|
|
|
|
// Get the actual editable element (might be nested for code blocks)
|
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 15:17:37 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
|
|
|
|
|
if (!editableElement) return '';
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
if (this.block.type === 'list') {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
const listItems = editableElement.querySelectorAll('li');
|
2025-06-24 10:45:06 +00:00
|
|
|
|
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
|
|
|
|
} else if (this.block.type === 'code') {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
return editableElement.textContent || '';
|
2025-06-24 10:45:06 +00:00
|
|
|
|
} else {
|
2025-06-24 16:53:54 +00:00
|
|
|
|
// For regular blocks, get the innerHTML which includes formatting tags
|
|
|
|
|
const content = editableElement.innerHTML || '';
|
|
|
|
|
console.log('Getting content from block:', content);
|
|
|
|
|
return content;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public setContent(content: string): void {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.setContent) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.setContent(container, content, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 13:53:47 +00:00
|
|
|
|
// Get the actual editable element (might be nested for code blocks)
|
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 15:17:37 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
|
|
|
|
|
if (!editableElement) return;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
// Store if we have focus
|
2025-06-24 13:53:47 +00:00
|
|
|
|
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
|
|
|
|
if (this.block.type === 'list') {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
|
2025-06-24 10:45:06 +00:00
|
|
|
|
} else if (this.block.type === 'code') {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.textContent = content;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
} else {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.innerHTML = content;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Restore focus if we had it
|
|
|
|
|
if (hadFocus) {
|
2025-06-24 13:53:47 +00:00
|
|
|
|
editableElement.focus();
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public setCursorToStart(): void {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.setCursorToStart) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.setCursorToStart(container, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 23:46:52 +00:00
|
|
|
|
// Always find the element fresh, don't rely on cached blockElement
|
2025-06-24 13:53:47 +00:00
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 23:46:52 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (editableElement) {
|
|
|
|
|
WysiwygBlocks.setCursorToStart(editableElement);
|
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public setCursorToEnd(): void {
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
if (handler && handler.setCursorToEnd) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
const context = { shadowRoot: this.shadowRoot!, component: this };
|
|
|
|
|
return handler.setCursorToEnd(container, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 23:46:52 +00:00
|
|
|
|
// Always find the element fresh, don't rely on cached blockElement
|
2025-06-24 13:53:47 +00:00
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
2025-06-24 23:46:52 +00:00
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
if (editableElement) {
|
|
|
|
|
WysiwygBlocks.setCursorToEnd(editableElement);
|
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public focusListItem(): void {
|
|
|
|
|
if (this.block.type === 'list') {
|
2025-06-24 15:17:37 +00:00
|
|
|
|
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
|
|
|
|
if (editableElement) {
|
|
|
|
|
WysiwygBlocks.focusListItem(editableElement);
|
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 20:32:03 +00:00
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup YouTube block functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupYouTubeBlock(): void {
|
|
|
|
|
const youtubeBlock = this.shadowRoot?.querySelector('.block.youtube') as HTMLDivElement;
|
|
|
|
|
if (!youtubeBlock) return;
|
|
|
|
|
|
|
|
|
|
// Handle click to select
|
|
|
|
|
youtubeBlock.addEventListener('click', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (!target.classList.contains('youtube-url-input') && !target.classList.contains('youtube-embed-btn')) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
youtubeBlock.focus();
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle URL input and embed button
|
|
|
|
|
const urlInput = youtubeBlock.querySelector('.youtube-url-input') as HTMLInputElement;
|
|
|
|
|
const embedBtn = youtubeBlock.querySelector('.youtube-embed-btn') as HTMLButtonElement;
|
|
|
|
|
|
|
|
|
|
if (urlInput && embedBtn) {
|
|
|
|
|
const embedVideo = () => {
|
|
|
|
|
const url = urlInput.value.trim();
|
|
|
|
|
if (url) {
|
|
|
|
|
// Extract video ID from YouTube URL
|
|
|
|
|
const videoId = this.extractYouTubeVideoId(url);
|
|
|
|
|
if (videoId) {
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, videoId, url };
|
|
|
|
|
this.block.content = url; // Store URL as content
|
|
|
|
|
|
|
|
|
|
// Re-render the block
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupYouTubeBlock(); // Re-setup event handlers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Notify parent of change
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
} else {
|
|
|
|
|
alert('Invalid YouTube URL');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
embedBtn.addEventListener('click', embedVideo);
|
|
|
|
|
urlInput.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
embedVideo();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle focus/blur
|
|
|
|
|
youtubeBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
|
|
|
|
|
youtubeBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
|
|
|
|
|
|
|
|
|
|
// Handle keyboard events
|
|
|
|
|
youtubeBlock.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
} else {
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup Markdown block functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupMarkdownBlock(): void {
|
|
|
|
|
const markdownBlock = this.shadowRoot?.querySelector('.block.markdown') as HTMLDivElement;
|
|
|
|
|
if (!markdownBlock) return;
|
|
|
|
|
|
|
|
|
|
// Handle click to select
|
|
|
|
|
markdownBlock.addEventListener('click', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (!target.classList.contains('markdown-editor') && !target.classList.contains('toggle-preview')) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
markdownBlock.focus();
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle preview toggle
|
|
|
|
|
const toggleBtn = markdownBlock.querySelector('.toggle-preview') as HTMLButtonElement;
|
|
|
|
|
if (toggleBtn) {
|
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
|
|
|
const showPreview = toggleBtn.dataset.active !== 'true';
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, showPreview };
|
|
|
|
|
|
|
|
|
|
// Re-render
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupMarkdownBlock();
|
|
|
|
|
|
|
|
|
|
// If switching to preview, render markdown
|
|
|
|
|
if (showPreview) {
|
|
|
|
|
this.renderMarkdownPreview();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle editor input
|
|
|
|
|
const editor = markdownBlock.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
|
|
|
|
if (editor) {
|
|
|
|
|
editor.addEventListener('input', () => {
|
|
|
|
|
this.block.content = editor.value;
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Auto-resize textarea
|
|
|
|
|
const autoResize = () => {
|
|
|
|
|
editor.style.height = 'auto';
|
|
|
|
|
editor.style.height = editor.scrollHeight + 'px';
|
|
|
|
|
};
|
|
|
|
|
editor.addEventListener('input', autoResize);
|
|
|
|
|
autoResize();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render preview if needed
|
|
|
|
|
if (this.block.metadata?.showPreview) {
|
|
|
|
|
this.renderMarkdownPreview();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle focus/blur
|
|
|
|
|
markdownBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
|
|
|
|
|
markdownBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
|
|
|
|
|
|
|
|
|
|
// Handle keyboard events
|
|
|
|
|
markdownBlock.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
} else {
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup HTML block functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupHtmlBlock(): void {
|
|
|
|
|
const htmlBlock = this.shadowRoot?.querySelector('.block.html') as HTMLDivElement;
|
|
|
|
|
if (!htmlBlock) return;
|
|
|
|
|
|
|
|
|
|
// Handle click to select
|
|
|
|
|
htmlBlock.addEventListener('click', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (!target.classList.contains('html-editor') && !target.classList.contains('toggle-preview')) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
htmlBlock.focus();
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle preview toggle
|
|
|
|
|
const toggleBtn = htmlBlock.querySelector('.toggle-preview') as HTMLButtonElement;
|
|
|
|
|
if (toggleBtn) {
|
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
|
|
|
const showPreview = toggleBtn.dataset.active !== 'true';
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, showPreview };
|
|
|
|
|
|
|
|
|
|
// Re-render
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupHtmlBlock();
|
|
|
|
|
|
|
|
|
|
// If switching to preview, render HTML
|
|
|
|
|
if (showPreview) {
|
|
|
|
|
this.renderHtmlPreview();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle editor input
|
|
|
|
|
const editor = htmlBlock.querySelector('.html-editor') as HTMLTextAreaElement;
|
|
|
|
|
if (editor) {
|
|
|
|
|
editor.addEventListener('input', () => {
|
|
|
|
|
this.block.content = editor.value;
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Auto-resize textarea
|
|
|
|
|
const autoResize = () => {
|
|
|
|
|
editor.style.height = 'auto';
|
|
|
|
|
editor.style.height = editor.scrollHeight + 'px';
|
|
|
|
|
};
|
|
|
|
|
editor.addEventListener('input', autoResize);
|
|
|
|
|
autoResize();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render preview if needed
|
|
|
|
|
if (this.block.metadata?.showPreview) {
|
|
|
|
|
this.renderHtmlPreview();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle focus/blur
|
|
|
|
|
htmlBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
|
|
|
|
|
htmlBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
|
|
|
|
|
|
|
|
|
|
// Handle keyboard events
|
|
|
|
|
htmlBlock.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
} else {
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Setup Attachment block functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupAttachmentBlock(): void {
|
|
|
|
|
const attachmentBlock = this.shadowRoot?.querySelector('.block.attachment') as HTMLDivElement;
|
|
|
|
|
if (!attachmentBlock) return;
|
|
|
|
|
|
|
|
|
|
// Handle click to select
|
|
|
|
|
attachmentBlock.addEventListener('click', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (!target.classList.contains('remove-file')) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
attachmentBlock.focus();
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle file input
|
|
|
|
|
const fileInput = attachmentBlock.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
|
const placeholder = attachmentBlock.querySelector('.attachment-placeholder');
|
|
|
|
|
const addMoreBtn = attachmentBlock.querySelector('.add-more-files') as HTMLButtonElement;
|
|
|
|
|
|
|
|
|
|
const triggerFileInput = () => {
|
|
|
|
|
if (fileInput) fileInput.click();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (placeholder) {
|
|
|
|
|
placeholder.addEventListener('click', triggerFileInput);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (addMoreBtn) {
|
|
|
|
|
addMoreBtn.addEventListener('click', triggerFileInput);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fileInput) {
|
|
|
|
|
fileInput.addEventListener('change', async (e) => {
|
|
|
|
|
const files = Array.from((e.target as HTMLInputElement).files || []);
|
|
|
|
|
if (files.length > 0) {
|
|
|
|
|
await this.handleFileAttachments(files);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle file removal
|
|
|
|
|
attachmentBlock.addEventListener('click', (e) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
|
|
|
|
if (target.classList.contains('remove-file')) {
|
|
|
|
|
const fileId = target.dataset.fileId;
|
|
|
|
|
if (fileId) {
|
|
|
|
|
const files = this.block.metadata?.files || [];
|
|
|
|
|
this.block.metadata = {
|
|
|
|
|
...this.block.metadata,
|
|
|
|
|
files: files.filter((f: any) => f.id !== fileId)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Re-render
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupAttachmentBlock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle drag and drop
|
|
|
|
|
attachmentBlock.addEventListener('dragover', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
attachmentBlock.classList.add('drag-over');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
attachmentBlock.addEventListener('dragleave', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
attachmentBlock.classList.remove('drag-over');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
attachmentBlock.addEventListener('drop', async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
attachmentBlock.classList.remove('drag-over');
|
|
|
|
|
|
|
|
|
|
const files = Array.from(e.dataTransfer?.files || []);
|
|
|
|
|
if (files.length > 0) {
|
|
|
|
|
await this.handleFileAttachments(files);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle focus/blur
|
|
|
|
|
attachmentBlock.addEventListener('focus', () => this.handlers?.onFocus?.());
|
|
|
|
|
attachmentBlock.addEventListener('blur', () => this.handlers?.onBlur?.());
|
|
|
|
|
|
|
|
|
|
// Handle keyboard events
|
|
|
|
|
attachmentBlock.addEventListener('keydown', (e) => {
|
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
} else {
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Extract YouTube video ID from URL
|
|
|
|
|
*/
|
|
|
|
|
private extractYouTubeVideoId(url: string): string | null {
|
|
|
|
|
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/;
|
|
|
|
|
const match = url.match(regex);
|
|
|
|
|
return match ? match[1] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render Markdown preview
|
|
|
|
|
*/
|
|
|
|
|
private async renderMarkdownPreview(): Promise<void> {
|
|
|
|
|
const preview = this.shadowRoot?.querySelector('.markdown-preview') as HTMLDivElement;
|
|
|
|
|
if (!preview || !this.block.content) return;
|
|
|
|
|
|
|
|
|
|
// Simple markdown to HTML conversion (you might want to use a proper markdown parser)
|
|
|
|
|
let html = this.block.content
|
|
|
|
|
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
|
|
|
|
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
|
|
|
|
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
|
|
|
|
.replace(/\*\*(.*)\*\*/g, '<strong>$1</strong>')
|
|
|
|
|
.replace(/\*(.*)\*/g, '<em>$1</em>')
|
|
|
|
|
.replace(/\[([^\]]*)\]\(([^\)]*)\)/g, '<a href="$2">$1</a>')
|
|
|
|
|
.replace(/\n/g, '<br>');
|
|
|
|
|
|
|
|
|
|
preview.innerHTML = html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render HTML preview
|
|
|
|
|
*/
|
|
|
|
|
private renderHtmlPreview(): void {
|
|
|
|
|
const preview = this.shadowRoot?.querySelector('.html-preview') as HTMLDivElement;
|
|
|
|
|
if (!preview || !this.block.content) return;
|
|
|
|
|
|
|
|
|
|
// Render HTML in a sandboxed way
|
|
|
|
|
preview.innerHTML = this.block.content;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle file attachments
|
|
|
|
|
*/
|
|
|
|
|
private async handleFileAttachments(files: File[]): Promise<void> {
|
|
|
|
|
const existingFiles = this.block.metadata?.files || [];
|
|
|
|
|
const newFiles: any[] = [];
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
// Convert to base64 for storage (in production, upload to server)
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
const base64 = await new Promise<string>((resolve) => {
|
|
|
|
|
reader.onload = (e) => resolve(e.target?.result as string);
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
newFiles.push({
|
|
|
|
|
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
|
|
|
name: file.name,
|
|
|
|
|
size: file.size,
|
|
|
|
|
type: file.type,
|
|
|
|
|
data: base64
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.block.metadata = {
|
|
|
|
|
...this.block.metadata,
|
|
|
|
|
files: [...existingFiles, ...newFiles]
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Re-render
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupAttachmentBlock();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
}
|
2025-06-24 18:43:51 +00:00
|
|
|
|
|
2025-06-24 20:32:03 +00:00
|
|
|
|
/**
|
|
|
|
|
* Get file icon based on mime type
|
|
|
|
|
*/
|
|
|
|
|
private getFileIcon(mimeType: string): string {
|
|
|
|
|
if (mimeType.startsWith('image/')) return '🖼️';
|
|
|
|
|
if (mimeType.startsWith('video/')) return '🎥';
|
|
|
|
|
if (mimeType.startsWith('audio/')) return '🎵';
|
|
|
|
|
if (mimeType.includes('pdf')) return '📄';
|
|
|
|
|
if (mimeType.includes('zip') || mimeType.includes('compressed')) return '🗄️';
|
|
|
|
|
if (mimeType.includes('sheet') || mimeType.includes('excel')) return '📊';
|
|
|
|
|
if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
|
|
|
|
|
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📋';
|
|
|
|
|
if (mimeType.includes('text')) return '📃';
|
|
|
|
|
return '📁';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format file size to human readable
|
|
|
|
|
*/
|
|
|
|
|
private 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];
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 17:16:13 +00:00
|
|
|
|
/**
|
|
|
|
|
* Setup image block functionality
|
|
|
|
|
*/
|
|
|
|
|
private setupImageBlock(): void {
|
|
|
|
|
const imageBlock = this.shadowRoot?.querySelector('.block.image') as HTMLDivElement;
|
|
|
|
|
if (!imageBlock) return;
|
|
|
|
|
|
2025-06-24 18:43:51 +00:00
|
|
|
|
// Note: tabindex is already set in the HTML
|
|
|
|
|
|
|
|
|
|
// Handle click to select the block
|
|
|
|
|
imageBlock.addEventListener('click', (e) => {
|
|
|
|
|
// Don't stop propagation for file input clicks
|
|
|
|
|
if ((e.target as HTMLElement).tagName !== 'INPUT') {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
// Focus will trigger the selection
|
|
|
|
|
imageBlock.focus();
|
2025-06-24 18:52:48 +00:00
|
|
|
|
// Ensure focus handler is called immediately
|
|
|
|
|
this.handlers?.onFocus?.();
|
2025-06-24 18:43:51 +00:00
|
|
|
|
}
|
|
|
|
|
});
|
2025-06-24 17:16:13 +00:00
|
|
|
|
|
|
|
|
|
// Handle click on upload placeholder
|
|
|
|
|
const uploadPlaceholder = imageBlock.querySelector('.image-upload-placeholder');
|
|
|
|
|
const fileInput = imageBlock.querySelector('input[type="file"]') as HTMLInputElement;
|
|
|
|
|
|
|
|
|
|
if (uploadPlaceholder && fileInput) {
|
|
|
|
|
uploadPlaceholder.addEventListener('click', () => {
|
|
|
|
|
fileInput.click();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileInput.addEventListener('change', (e) => {
|
|
|
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
|
|
|
if (file) {
|
|
|
|
|
this.handleImageUpload(file);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle drag and drop
|
|
|
|
|
imageBlock.addEventListener('dragover', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
uploadPlaceholder.classList.add('drag-over');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
imageBlock.addEventListener('dragleave', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
uploadPlaceholder.classList.remove('drag-over');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
imageBlock.addEventListener('drop', (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
uploadPlaceholder.classList.remove('drag-over');
|
|
|
|
|
|
|
|
|
|
const files = e.dataTransfer?.files;
|
|
|
|
|
if (files && files.length > 0) {
|
|
|
|
|
const file = files[0];
|
|
|
|
|
if (file.type.startsWith('image/')) {
|
|
|
|
|
this.handleImageUpload(file);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle focus/blur for the image block
|
|
|
|
|
imageBlock.addEventListener('focus', () => {
|
|
|
|
|
this.handlers?.onFocus?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
imageBlock.addEventListener('blur', () => {
|
|
|
|
|
this.handlers?.onBlur?.();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Handle keyboard events
|
|
|
|
|
imageBlock.addEventListener('keydown', (e) => {
|
2025-06-24 18:43:51 +00:00
|
|
|
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
// Let the keyboard handler in the parent component handle the deletion
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
} else {
|
|
|
|
|
// Handle navigation keys
|
|
|
|
|
this.handlers?.onKeyDown?.(e);
|
|
|
|
|
}
|
2025-06-24 17:16:13 +00:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle image file upload
|
|
|
|
|
*/
|
|
|
|
|
private async handleImageUpload(file: File): Promise<void> {
|
|
|
|
|
// Check file size (max 10MB)
|
|
|
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
|
|
|
alert('Image size must be less than 10MB');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update block to show loading state
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, loading: true };
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupImageBlock(); // Re-setup event handlers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Convert to base64 for now (in production, you'd upload to a server)
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = (e) => {
|
|
|
|
|
const base64 = e.target?.result as string;
|
|
|
|
|
|
|
|
|
|
// Update block with image URL
|
|
|
|
|
this.block.metadata = {
|
|
|
|
|
...this.block.metadata,
|
|
|
|
|
url: base64,
|
|
|
|
|
loading: false,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
fileSize: file.size,
|
|
|
|
|
mimeType: file.type
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Set alt text as content
|
|
|
|
|
this.block.content = file.name.replace(/\.[^/.]+$/, ''); // Remove extension
|
|
|
|
|
|
|
|
|
|
// Re-render
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Notify parent component of the change
|
|
|
|
|
this.handlers?.onInput?.(new InputEvent('input'));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
reader.onerror = () => {
|
|
|
|
|
alert('Failed to read image file');
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, loading: false };
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupImageBlock();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error uploading image:', error);
|
|
|
|
|
alert('Failed to upload image');
|
|
|
|
|
this.block.metadata = { ...this.block.metadata, loading: false };
|
|
|
|
|
if (container) {
|
|
|
|
|
container.innerHTML = this.renderBlockContent();
|
|
|
|
|
this.setupImageBlock();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
|
/**
|
|
|
|
|
* Gets content split at cursor position
|
|
|
|
|
*/
|
|
|
|
|
public getSplitContent(): { before: string; after: string } | null {
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getSplitContent: Starting...');
|
2025-06-24 13:41:12 +00:00
|
|
|
|
|
2025-06-24 22:45:50 +00:00
|
|
|
|
// Check if we have a registered handler for this block type
|
|
|
|
|
const handler = BlockRegistry.getHandler(this.block.type);
|
|
|
|
|
console.log('getSplitContent: Checking for handler', {
|
|
|
|
|
blockType: this.block.type,
|
|
|
|
|
hasHandler: !!handler,
|
|
|
|
|
hasSplitMethod: !!(handler && handler.getSplitContent)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (handler && handler.getSplitContent) {
|
|
|
|
|
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
|
|
|
|
console.log('getSplitContent: Found container', {
|
|
|
|
|
container: !!container,
|
|
|
|
|
containerHTML: container?.innerHTML?.substring(0, 100)
|
|
|
|
|
});
|
|
|
|
|
const context = {
|
|
|
|
|
shadowRoot: this.shadowRoot!,
|
|
|
|
|
component: this
|
|
|
|
|
};
|
|
|
|
|
return handler.getSplitContent(container, context);
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 17:16:13 +00:00
|
|
|
|
// Image blocks can't be split
|
|
|
|
|
if (this.block?.type === 'image') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Get the actual editable element first
|
|
|
|
|
const editableElement = this.block?.type === 'code'
|
|
|
|
|
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
|
|
|
|
|
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
|
|
|
|
|
|
|
|
|
if (!editableElement) {
|
|
|
|
|
console.log('getSplitContent: No editable element found');
|
|
|
|
|
return null;
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getSplitContent: Element info:', {
|
|
|
|
|
blockType: this.block.type,
|
|
|
|
|
innerHTML: editableElement.innerHTML,
|
|
|
|
|
textContent: editableElement.textContent,
|
|
|
|
|
textLength: editableElement.textContent?.length
|
2025-06-24 13:53:47 +00:00
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// Get parent wysiwyg component's shadow root
|
|
|
|
|
const parentComponent = this.closest('dees-input-wysiwyg');
|
|
|
|
|
const parentShadowRoot = parentComponent?.shadowRoot;
|
|
|
|
|
|
|
|
|
|
// Get selection info with both shadow roots for proper traversal
|
|
|
|
|
const shadowRoots: ShadowRoot[] = [];
|
|
|
|
|
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
|
|
|
|
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
|
|
|
|
console.log('getSplitContent: Selection info from shadow DOMs:', {
|
|
|
|
|
selectionInfo,
|
|
|
|
|
shadowRootsCount: shadowRoots.length
|
2025-06-24 13:41:12 +00:00
|
|
|
|
});
|
2025-06-24 10:45:06 +00:00
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
if (!selectionInfo) {
|
|
|
|
|
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
|
|
|
|
|
// Try using last known cursor position
|
|
|
|
|
if (this.lastKnownCursorPosition !== null) {
|
|
|
|
|
const fullText = editableElement.textContent || '';
|
|
|
|
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
|
|
|
|
console.log('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)
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-06-24 13:53:47 +00:00
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getSplitContent: Selection range:', {
|
|
|
|
|
startContainer: selectionInfo.startContainer,
|
|
|
|
|
startOffset: selectionInfo.startOffset,
|
|
|
|
|
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Make sure the selection is within this block
|
2025-06-24 22:45:50 +00:00
|
|
|
|
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
|
|
|
|
|
// Try using last known cursor position
|
|
|
|
|
if (this.lastKnownCursorPosition !== null) {
|
|
|
|
|
const fullText = editableElement.textContent || '';
|
|
|
|
|
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
|
|
|
|
return {
|
|
|
|
|
before: fullText.substring(0, pos),
|
|
|
|
|
after: fullText.substring(pos)
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-06-24 13:53:47 +00:00
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
// For code blocks, use simple text splitting
|
2025-06-24 13:41:12 +00:00
|
|
|
|
if (this.block.type === 'code') {
|
2025-06-24 15:17:37 +00:00
|
|
|
|
const cursorPos = this.getCursorPosition(editableElement) || 0;
|
2025-06-24 13:53:47 +00:00
|
|
|
|
const fullText = editableElement.textContent || '';
|
2025-06-24 13:41:12 +00:00
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
console.log('getSplitContent: Code block split:', {
|
|
|
|
|
cursorPos,
|
|
|
|
|
fullTextLength: fullText.length,
|
|
|
|
|
before: fullText.substring(0, cursorPos),
|
|
|
|
|
after: fullText.substring(cursorPos)
|
2025-06-24 13:41:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 15:17:37 +00:00
|
|
|
|
return {
|
|
|
|
|
before: fullText.substring(0, cursorPos),
|
|
|
|
|
after: fullText.substring(cursorPos)
|
2025-06-24 13:41:12 +00:00
|
|
|
|
};
|
|
|
|
|
}
|
2025-06-24 15:17:37 +00:00
|
|
|
|
|
|
|
|
|
// For HTML content, get cursor position first
|
|
|
|
|
const cursorPos = this.getCursorPosition(editableElement);
|
|
|
|
|
console.log('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('getSplitContent: Cursor at start or null, moving all content');
|
|
|
|
|
return {
|
|
|
|
|
before: '',
|
|
|
|
|
after: editableElement.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(editableElement, 0);
|
|
|
|
|
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
|
|
|
|
|
|
|
|
|
// After range: from cursor to end of element
|
|
|
|
|
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
|
|
|
|
afterRange.setEnd(editableElement, editableElement.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('getSplitContent: Final split result:', {
|
|
|
|
|
cursorPos,
|
|
|
|
|
beforeHtml,
|
|
|
|
|
beforeLength: beforeHtml.length,
|
2025-06-24 15:52:28 +00:00
|
|
|
|
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
2025-06-24 15:17:37 +00:00
|
|
|
|
afterHtml,
|
2025-06-24 15:52:28 +00:00
|
|
|
|
afterLength: afterHtml.length,
|
|
|
|
|
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
2025-06-24 15:17:37 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
before: beforeHtml,
|
|
|
|
|
after: afterHtml
|
|
|
|
|
};
|
2025-06-24 10:45:06 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|