fix(dees-modal): theming
This commit is contained in:
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
551
ts_web/elements/wysiwyg/dees-wysiwyg-block.ts
Normal file
@ -0,0 +1,551 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
query,
|
||||
unsafeStatic,
|
||||
static as staticHtml,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-wysiwyg-block': DeesWysiwygBlock;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-wysiwyg-block')
|
||||
export class DeesWysiwygBlock extends DeesElement {
|
||||
@property({ type: Object })
|
||||
public block: IBlock;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public isSelected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
public handlers: {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
};
|
||||
|
||||
@query('.block')
|
||||
private blockElement: HTMLDivElement;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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: 20px 0;
|
||||
}
|
||||
|
||||
.block.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.block.divider {
|
||||
padding: 0;
|
||||
margin: 16px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
margin: 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')};
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
protected shouldUpdate(): boolean {
|
||||
// Only update if the block type or id changes
|
||||
// Content changes are handled directly in the DOM
|
||||
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.block) return html``;
|
||||
|
||||
if (this.block.type === 'divider') {
|
||||
return html`
|
||||
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.block.type === 'code') {
|
||||
const language = this.block.metadata?.language || 'plain text';
|
||||
return html`
|
||||
<div class="code-block-container">
|
||||
<div class="code-language">${language}</div>
|
||||
<div
|
||||
class="block code ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-block-type="${this.block.type}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
.textContent="${this.block.content || ''}"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const placeholder = this.getPlaceholder();
|
||||
const initialContent = this.getInitialContent();
|
||||
|
||||
return staticHtml`
|
||||
<div
|
||||
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
@input="${this.handlers?.onInput}"
|
||||
@keydown="${this.handlers?.onKeyDown}"
|
||||
@focus="${this.handlers?.onFocus}"
|
||||
@blur="${this.handlers?.onBlur}"
|
||||
@compositionstart="${this.handlers?.onCompositionStart}"
|
||||
@compositionend="${this.handlers?.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
this.handleMouseUp(e);
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
}}"
|
||||
>${unsafeStatic(initialContent)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getInitialContent(): string {
|
||||
if (this.block.type === 'list') {
|
||||
return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata);
|
||||
}
|
||||
return this.block.content || '';
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!this.blockElement.hasAttribute('contenteditable')) {
|
||||
this.blockElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
this.blockElement.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== this.blockElement) {
|
||||
Promise.resolve().then(() => {
|
||||
this.blockElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!this.blockElement.hasAttribute('contenteditable')) {
|
||||
this.blockElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// Focus the element
|
||||
this.blockElement.focus();
|
||||
|
||||
// Set cursor position after focus is established
|
||||
const setCursor = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart();
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd();
|
||||
} else if (typeof position === 'number') {
|
||||
// Set cursor at specific position
|
||||
const range = document.createRange();
|
||||
const textNode = this.getFirstTextNode(this.blockElement);
|
||||
|
||||
if (textNode) {
|
||||
const length = textNode.textContent?.length || 0;
|
||||
const safePosition = Math.min(position, length);
|
||||
range.setStart(textNode, safePosition);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else if (this.blockElement.childNodes.length === 0) {
|
||||
// Empty block - create a text node
|
||||
const emptyText = document.createTextNode('');
|
||||
this.blockElement.appendChild(emptyText);
|
||||
range.setStart(emptyText, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === this.blockElement) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === this.blockElement) {
|
||||
setCursor();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getFirstTextNode(node: Node): Text | null {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node as Text;
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const textNode = this.getFirstTextNode(node.childNodes[i]);
|
||||
if (textNode) return textNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public getContent(): string {
|
||||
if (!this.blockElement) return '';
|
||||
|
||||
if (this.block.type === 'list') {
|
||||
const listItems = this.blockElement.querySelectorAll('li');
|
||||
return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||
} else if (this.block.type === 'code') {
|
||||
return this.blockElement.textContent || '';
|
||||
} else {
|
||||
return this.blockElement.innerHTML || '';
|
||||
}
|
||||
}
|
||||
|
||||
public setContent(content: string): void {
|
||||
if (!this.blockElement) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === this.blockElement;
|
||||
|
||||
if (this.block.type === 'list') {
|
||||
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
|
||||
} else if (this.block.type === 'code') {
|
||||
this.blockElement.textContent = content;
|
||||
} else {
|
||||
this.blockElement.innerHTML = content;
|
||||
}
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
this.blockElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public setCursorToStart(): void {
|
||||
WysiwygBlocks.setCursorToStart(this.blockElement);
|
||||
}
|
||||
|
||||
public setCursorToEnd(): void {
|
||||
WysiwygBlocks.setCursorToEnd(this.blockElement);
|
||||
}
|
||||
|
||||
public focusListItem(): void {
|
||||
if (this.block.type === 'list') {
|
||||
WysiwygBlocks.focusListItem(this.blockElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets content split at cursor position
|
||||
*/
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
if (!this.blockElement) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return {
|
||||
before: this.getContent(),
|
||||
after: ''
|
||||
};
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if selection is within this block
|
||||
if (!this.blockElement.contains(range.commonAncestorContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clone the range to extract content before and after cursor
|
||||
const beforeRange = range.cloneRange();
|
||||
beforeRange.selectNodeContents(this.blockElement);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
const afterRange = range.cloneRange();
|
||||
afterRange.selectNodeContents(this.blockElement);
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
|
||||
// Extract content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
private handleMouseUp(_e: MouseEvent): void {
|
||||
// Check if we have a selection within this block
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Check if selection is within this block
|
||||
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) {
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length > 0) {
|
||||
// Dispatch a custom event that can cross shadow DOM boundaries
|
||||
this.dispatchEvent(new CustomEvent('block-text-selected', {
|
||||
detail: {
|
||||
text: selectedText,
|
||||
blockId: this.block.id,
|
||||
range: range
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user