fix(structure): group components into groups inside the repo
This commit is contained in:
184
ts_web/elements/00group-input/dees-input-base/dees-input-base.ts
Normal file
184
ts_web/elements/00group-input/dees-input-base/dees-input-base.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
DeesElement,
|
||||
property,
|
||||
css,
|
||||
type CSSResult,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
|
||||
/**
|
||||
* Base class for all dees-input components
|
||||
* Provides unified margin system and layout mode support
|
||||
*/
|
||||
export abstract class DeesInputBase<T = any> extends DeesElement {
|
||||
/**
|
||||
* Layout mode for the input component
|
||||
* - vertical: Traditional form layout (label on top)
|
||||
* - horizontal: Inline layout (label position configurable)
|
||||
* - auto: Detect from parent context
|
||||
*/
|
||||
@property({ type: String })
|
||||
accessor layoutMode: 'vertical' | 'horizontal' | 'auto' = 'auto';
|
||||
|
||||
/**
|
||||
* Position of the label relative to the input
|
||||
*/
|
||||
@property({ type: String })
|
||||
accessor labelPosition: 'top' | 'left' | 'right' | 'none' = 'top';
|
||||
|
||||
/**
|
||||
* Common properties for all inputs
|
||||
*/
|
||||
@property({ type: String })
|
||||
accessor key: string;
|
||||
|
||||
@property({ type: String })
|
||||
accessor label: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor required: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor disabled: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor description: string;
|
||||
|
||||
/**
|
||||
* Common styles for all input components
|
||||
*/
|
||||
public static get baseStyles(): CSSResult[] {
|
||||
return [
|
||||
css`
|
||||
/* CSS Variables for consistent spacing */
|
||||
:host {
|
||||
--dees-input-spacing-unit: 8px;
|
||||
--dees-input-vertical-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
|
||||
--dees-input-horizontal-gap: calc(var(--dees-input-spacing-unit) * 2); /* 16px */
|
||||
--dees-input-label-gap: var(--dees-input-spacing-unit); /* 8px */
|
||||
}
|
||||
|
||||
/* Default vertical stacking mode (for forms) */
|
||||
:host {
|
||||
display: block;
|
||||
margin: 0;
|
||||
margin-bottom: var(--dees-input-vertical-gap);
|
||||
}
|
||||
|
||||
/* Last child in container should have no bottom margin */
|
||||
:host(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Horizontal layout mode - activated by attribute */
|
||||
:host([layout-mode="horizontal"]) {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
margin-right: var(--dees-input-horizontal-gap);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:host([layout-mode="horizontal"]:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Auto mode - inherit from parent dees-form if present */
|
||||
|
||||
/* Label position variations */
|
||||
:host([label-position="left"]) .input-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--dees-input-label-gap);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:host([label-position="right"]) .input-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--dees-input-label-gap);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:host([label-position="top"]) .input-wrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host([label-position="none"]) dees-label {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subject for value changes that all inputs should implement
|
||||
*/
|
||||
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<T>();
|
||||
|
||||
/**
|
||||
* Called when the element is connected to the DOM
|
||||
* Sets up layout mode detection
|
||||
*/
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
this.detectLayoutMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the appropriate layout mode based on parent context
|
||||
*/
|
||||
private detectLayoutMode() {
|
||||
if (this.layoutMode !== 'auto') {
|
||||
this.setAttribute('layout-mode', this.layoutMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if parent is a form with horizontal layout
|
||||
const parentForm = this.closest('dees-form');
|
||||
if (parentForm && parentForm.hasAttribute('horizontal-layout')) {
|
||||
this.setAttribute('layout-mode', 'horizontal');
|
||||
} else {
|
||||
this.setAttribute('layout-mode', 'vertical');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the layout mode attribute when property changes
|
||||
*/
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('layoutMode')) {
|
||||
this.detectLayoutMode();
|
||||
}
|
||||
|
||||
if (changedProperties.has('labelPosition')) {
|
||||
this.setAttribute('label-position', this.labelPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard method for freezing input (disabling)
|
||||
*/
|
||||
public async freeze() {
|
||||
this.disabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard method for unfreezing input (enabling)
|
||||
*/
|
||||
public async unfreeze() {
|
||||
this.disabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method that child classes must implement to get their value
|
||||
*/
|
||||
public abstract getValue(): any;
|
||||
|
||||
/**
|
||||
* Abstract method that child classes must implement to set their value
|
||||
*/
|
||||
public abstract setValue(value: any): void;
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-base/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-base/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-base.js';
|
||||
@@ -0,0 +1,307 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import type { DeesInputCheckbox } from '../dees-input-checkbox/dees-input-checkbox.js';
|
||||
import '../../00group-button/dees-button/dees-button.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Get all checkboxes for demo interactions
|
||||
const checkboxes = elementArg.querySelectorAll('dees-input-checkbox');
|
||||
|
||||
// Example of programmatic interaction
|
||||
const selectAllBtn = elementArg.querySelector('#select-all-btn');
|
||||
const clearAllBtn = elementArg.querySelector('#clear-all-btn');
|
||||
|
||||
if (selectAllBtn && clearAllBtn) {
|
||||
selectAllBtn.addEventListener('click', () => {
|
||||
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
|
||||
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
|
||||
checkbox.value = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
clearAllBtn.addEventListener('click', () => {
|
||||
checkboxes.forEach((checkbox: DeesInputCheckbox) => {
|
||||
if (!checkbox.disabled && checkbox.key?.startsWith('feature')) {
|
||||
checkbox.value = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.horizontal-checkboxes {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.interactive-section {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
|
||||
padding: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Basic Checkboxes'} .subtitle=${'Simple checkbox examples with various labels'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'I agree to the Terms and Conditions'}
|
||||
.value=${true}
|
||||
.key=${'terms'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Subscribe to newsletter'}
|
||||
.value=${false}
|
||||
.key=${'newsletter'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Enable notifications'}
|
||||
.value=${false}
|
||||
.description=${'Receive email updates about your account'}
|
||||
.key=${'notifications'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Checkbox States'} .subtitle=${'Different checkbox states and configurations'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Default state'}
|
||||
.value=${false}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Checked state'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled unchecked'}
|
||||
.value=${false}
|
||||
.disabled=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Disabled checked'}
|
||||
.value=${true}
|
||||
.disabled=${true}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Required checkbox'}
|
||||
.required=${true}
|
||||
.key=${'required'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Checkboxes arranged horizontally for compact forms'}>
|
||||
<div class="horizontal-checkboxes">
|
||||
<dees-input-checkbox
|
||||
.label=${'Option A'}
|
||||
.value=${false}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionA'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option B'}
|
||||
.value=${true}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionB'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option C'}
|
||||
.value=${false}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionC'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Option D'}
|
||||
.value=${true}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'optionD'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Feature Selection Example'} .subtitle=${'Common use case for feature toggles with batch operations'}>
|
||||
<div class="button-group">
|
||||
<dees-button id="select-all-btn" type="secondary">Select All</dees-button>
|
||||
<dees-button id="clear-all-btn" type="secondary">Clear All</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="feature-list">
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Dark Mode Support'}
|
||||
.value=${true}
|
||||
.key=${'feature1'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Email Notifications'}
|
||||
.value=${true}
|
||||
.key=${'feature2'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Two-Factor Authentication'}
|
||||
.value=${false}
|
||||
.key=${'feature3'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'API Access'}
|
||||
.value=${true}
|
||||
.key=${'feature4'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Advanced Analytics'}
|
||||
.value=${false}
|
||||
.key=${'feature5'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Privacy Settings Example'} .subtitle=${'Checkboxes in a typical form context'}>
|
||||
<div class="form-section">
|
||||
<h4 class="section-title">Privacy Preferences</h4>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Share analytics data'}
|
||||
.value=${true}
|
||||
.description=${'Help us improve by sharing anonymous usage data'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Personalized recommendations'}
|
||||
.value=${true}
|
||||
.description=${'Get suggestions based on your activity'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Marketing communications'}
|
||||
.value=${false}
|
||||
.description=${'Receive promotional emails and special offers'}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Third-party integrations'}
|
||||
.value=${false}
|
||||
.description=${'Allow approved partners to access your data'}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Interactive Example'} .subtitle=${'Click checkboxes to see value changes'}>
|
||||
<div class="checkbox-group">
|
||||
<dees-input-checkbox
|
||||
.label=${'Feature toggle'}
|
||||
.value=${false}
|
||||
@changeSubject=${(event: CustomEvent) => {
|
||||
const output = document.querySelector('#checkbox-output');
|
||||
if (output && event.detail) {
|
||||
const isChecked = event.detail.getValue();
|
||||
output.textContent = `Feature is ${isChecked ? 'enabled' : 'disabled'}`;
|
||||
}
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
|
||||
<dees-input-checkbox
|
||||
.label=${'Debug mode'}
|
||||
.value=${false}
|
||||
@changeSubject=${(event: CustomEvent) => {
|
||||
const output = document.querySelector('#debug-output');
|
||||
if (output && event.detail) {
|
||||
const isChecked = event.detail.getValue();
|
||||
output.textContent = `Debug mode: ${isChecked ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="interactive-section">
|
||||
<div id="checkbox-output" class="output-text">Feature is disabled</div>
|
||||
<div id="debug-output" class="output-text" style="margin-top: 8px;">Debug mode: OFF</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-checkbox.demo.js';
|
||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-checkbox': DeesInputCheckbox;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-checkbox')
|
||||
export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor value: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor indeterminate: boolean = false;
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.labelPosition = 'right'; // Checkboxes default to label on the right
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
transition: all 0.15s ease;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.checkbox.selected {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
}
|
||||
|
||||
.checkbox:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
/* Checkmark using Lucide icon style */
|
||||
.checkbox .checkmark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.checkbox.selected .checkmark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.checkbox .checkmark svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: white;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
.maincontainer.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.checkbox.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.label-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
transition: color 0.15s ease;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maincontainer:hover .checkbox-label {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.maincontainer.disabled:hover .checkbox-label {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.description-text {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<div class="maincontainer ${this.disabled ? 'disabled' : ''}" @click="${this.toggleSelected}">
|
||||
<div
|
||||
class="checkbox ${this.value ? 'selected' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${this.handleKeydown}"
|
||||
>
|
||||
${this.value
|
||||
? html`
|
||||
<span class="checkmark">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
: this.indeterminate
|
||||
? html`
|
||||
<span class="checkmark">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="label-container">
|
||||
${this.label ? html`<div class="checkbox-label">${this.label}</div>` : ''}
|
||||
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async toggleSelected() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
this.value = !this.value;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('newValue', {
|
||||
detail: this.value,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): boolean {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: boolean): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
const checkboxDiv = this.shadowRoot.querySelector('.checkbox');
|
||||
if (checkboxDiv) {
|
||||
(checkboxDiv as any).focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.toggleSelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-checkbox.js';
|
||||
624
ts_web/elements/00group-input/dees-input-datepicker/component.ts
Normal file
624
ts_web/elements/00group-input/dees-input-datepicker/component.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { datepickerStyles } from './styles.js';
|
||||
import { renderDatepicker } from './template.js';
|
||||
import type { IDateEvent } from './types.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import '../../dees-label/dees-label.js';
|
||||
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-datepicker': DeesInputDatepicker;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-datepicker')
|
||||
export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
accessor value: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor enableTime: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor timeFormat: '24h' | '12h' = '24h';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor minuteIncrement: number = 1;
|
||||
|
||||
@property({ type: String })
|
||||
accessor dateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
@property({ type: String })
|
||||
accessor minDate: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor maxDate: string = '';
|
||||
|
||||
@property({ type: Array })
|
||||
accessor disabledDates: string[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
accessor weekStartsOn: 0 | 1 = 1; // Default to Monday
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = 'YYYY-MM-DD';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor enableTimezone: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@property({ type: Array })
|
||||
accessor events: IDateEvent[] = [];
|
||||
|
||||
@state()
|
||||
accessor isOpened: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor opensToTop: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor selectedDate: Date | null = null;
|
||||
|
||||
@state()
|
||||
accessor viewDate: Date = new Date();
|
||||
|
||||
@state()
|
||||
accessor selectedHour: number = 0;
|
||||
|
||||
@state()
|
||||
accessor selectedMinute: number = 0;
|
||||
|
||||
public static styles = datepickerStyles;
|
||||
|
||||
|
||||
|
||||
public getTimezones(): { value: string; label: string }[] {
|
||||
// Common timezones with their display names
|
||||
return [
|
||||
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
|
||||
{ value: 'America/New_York', label: 'Eastern Time (US & Canada)' },
|
||||
{ value: 'America/Chicago', label: 'Central Time (US & Canada)' },
|
||||
{ value: 'America/Denver', label: 'Mountain Time (US & Canada)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' },
|
||||
{ value: 'America/Phoenix', label: 'Arizona' },
|
||||
{ value: 'America/Anchorage', label: 'Alaska' },
|
||||
{ value: 'Pacific/Honolulu', label: 'Hawaii' },
|
||||
{ value: 'Europe/London', label: 'London' },
|
||||
{ value: 'Europe/Paris', label: 'Paris' },
|
||||
{ value: 'Europe/Berlin', label: 'Berlin' },
|
||||
{ value: 'Europe/Moscow', label: 'Moscow' },
|
||||
{ value: 'Asia/Dubai', label: 'Dubai' },
|
||||
{ value: 'Asia/Kolkata', label: 'India Standard Time' },
|
||||
{ value: 'Asia/Shanghai', label: 'China Standard Time' },
|
||||
{ value: 'Asia/Tokyo', label: 'Tokyo' },
|
||||
{ value: 'Australia/Sydney', label: 'Sydney' },
|
||||
{ value: 'Pacific/Auckland', label: 'Auckland' },
|
||||
];
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderDatepicker(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
// Initialize with empty value if not set
|
||||
if (!this.value) {
|
||||
this.value = '';
|
||||
}
|
||||
|
||||
// Initialize view date and selected time
|
||||
if (this.value) {
|
||||
try {
|
||||
const date = new Date(this.value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
this.selectedDate = date;
|
||||
this.viewDate = new Date(date);
|
||||
this.selectedHour = date.getHours();
|
||||
this.selectedMinute = date.getMinutes();
|
||||
}
|
||||
} catch {
|
||||
// Invalid date
|
||||
}
|
||||
} else {
|
||||
const now = new Date();
|
||||
this.viewDate = new Date(now);
|
||||
this.selectedHour = now.getHours();
|
||||
this.selectedMinute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public formatDate(isoString: string): string {
|
||||
if (!isoString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
let formatted = this.dateFormat;
|
||||
|
||||
// Basic date formatting
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString();
|
||||
|
||||
// Replace in correct order to avoid conflicts
|
||||
formatted = formatted.replace('YYYY', year);
|
||||
formatted = formatted.replace('YY', year.slice(-2));
|
||||
formatted = formatted.replace('MM', month);
|
||||
formatted = formatted.replace('DD', day);
|
||||
|
||||
// Time formatting if enabled
|
||||
if (this.enableTime) {
|
||||
const hours24 = date.getHours();
|
||||
const hours12 = hours24 === 0 ? 12 : hours24 > 12 ? hours24 - 12 : hours24;
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const ampm = hours24 >= 12 ? 'PM' : 'AM';
|
||||
|
||||
if (this.timeFormat === '12h') {
|
||||
formatted += ` ${hours12}:${minutes} ${ampm}`;
|
||||
} else {
|
||||
formatted += ` ${hours24.toString().padStart(2, '0')}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Timezone formatting if enabled
|
||||
if (this.enableTimezone) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZoneName: 'short',
|
||||
timeZone: this.timezone
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const tzPart = parts.find(part => part.type === 'timeZoneName');
|
||||
if (tzPart) {
|
||||
formatted += ` ${tzPart.value}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private handleClickOutside = (event: MouseEvent) => {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this.isOpened = false;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
};
|
||||
|
||||
public async toggleCalendar(): Promise<void> {
|
||||
if (this.disabled) return;
|
||||
|
||||
this.isOpened = !this.isOpened;
|
||||
|
||||
if (this.isOpened) {
|
||||
// Check available space and set position
|
||||
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
|
||||
const rect = inputContainer.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Determine if we should open upwards (approximate height of 400px)
|
||||
this.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
|
||||
|
||||
// Add click outside listener
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}, 0);
|
||||
} else {
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
public getDaysInMonth(): Date[] {
|
||||
const year = this.viewDate.getFullYear();
|
||||
const month = this.viewDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const days: Date[] = [];
|
||||
|
||||
// Adjust for week start
|
||||
const startOffset = this.weekStartsOn === 1
|
||||
? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1)
|
||||
: firstDay.getDay();
|
||||
|
||||
// Add days from previous month
|
||||
for (let i = startOffset; i > 0; i--) {
|
||||
days.push(new Date(year, month, 1 - i));
|
||||
}
|
||||
|
||||
// Add days of current month
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push(new Date(year, month, i));
|
||||
}
|
||||
|
||||
// Add days from next month to complete the grid (6 rows)
|
||||
const remainingDays = 42 - days.length;
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push(new Date(year, month + 1, i));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
public isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
}
|
||||
|
||||
public isSelected(date: Date): boolean {
|
||||
if (!this.selectedDate) return false;
|
||||
return date.getDate() === this.selectedDate.getDate() &&
|
||||
date.getMonth() === this.selectedDate.getMonth() &&
|
||||
date.getFullYear() === this.selectedDate.getFullYear();
|
||||
}
|
||||
|
||||
public isDisabled(date: Date): boolean {
|
||||
// Check min date
|
||||
if (this.minDate) {
|
||||
const min = new Date(this.minDate);
|
||||
if (date < min) return true;
|
||||
}
|
||||
|
||||
// Check max date
|
||||
if (this.maxDate) {
|
||||
const max = new Date(this.maxDate);
|
||||
if (date > max) return true;
|
||||
}
|
||||
|
||||
// Check disabled dates
|
||||
if (this.disabledDates && this.disabledDates.length > 0) {
|
||||
return this.disabledDates.some(disabledStr => {
|
||||
try {
|
||||
const disabled = new Date(disabledStr);
|
||||
return date.getDate() === disabled.getDate() &&
|
||||
date.getMonth() === disabled.getMonth() &&
|
||||
date.getFullYear() === disabled.getFullYear();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public getEventsForDate(date: Date): IDateEvent[] {
|
||||
if (!this.events || this.events.length === 0) return [];
|
||||
|
||||
const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
|
||||
return this.events.filter(event => event.date === dateStr);
|
||||
}
|
||||
|
||||
public selectDate(date: Date): void {
|
||||
this.selectedDate = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
this.selectedHour,
|
||||
this.selectedMinute
|
||||
);
|
||||
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
|
||||
if (!this.enableTime) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public selectToday(): void {
|
||||
const today = new Date();
|
||||
this.selectedDate = today;
|
||||
this.viewDate = new Date(today);
|
||||
this.selectedHour = today.getHours();
|
||||
this.selectedMinute = today.getMinutes();
|
||||
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
|
||||
if (!this.enableTime) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
this.isOpened = false;
|
||||
}
|
||||
|
||||
public previousMonth(): void {
|
||||
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1);
|
||||
}
|
||||
|
||||
public nextMonth(): void {
|
||||
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
|
||||
}
|
||||
|
||||
public handleHourInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
let value = parseInt(input.value) || 0;
|
||||
|
||||
if (this.timeFormat === '12h') {
|
||||
value = Math.max(1, Math.min(12, value));
|
||||
// Convert to 24h format
|
||||
if (this.selectedHour >= 12 && value !== 12) {
|
||||
this.selectedHour = value + 12;
|
||||
} else if (this.selectedHour < 12 && value === 12) {
|
||||
this.selectedHour = 0;
|
||||
} else {
|
||||
this.selectedHour = value;
|
||||
}
|
||||
} else {
|
||||
this.selectedHour = Math.max(0, Math.min(23, value));
|
||||
}
|
||||
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
public handleMinuteInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
let value = parseInt(input.value) || 0;
|
||||
value = Math.max(0, Math.min(59, value));
|
||||
|
||||
if (this.minuteIncrement && this.minuteIncrement > 1) {
|
||||
value = Math.round(value / this.minuteIncrement) * this.minuteIncrement;
|
||||
}
|
||||
|
||||
this.selectedMinute = value;
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
public setAMPM(period: 'am' | 'pm'): void {
|
||||
if (period === 'am' && this.selectedHour >= 12) {
|
||||
this.selectedHour -= 12;
|
||||
} else if (period === 'pm' && this.selectedHour < 12) {
|
||||
this.selectedHour += 12;
|
||||
}
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
private updateSelectedDateTime(): void {
|
||||
if (this.selectedDate) {
|
||||
this.selectedDate = new Date(
|
||||
this.selectedDate.getFullYear(),
|
||||
this.selectedDate.getMonth(),
|
||||
this.selectedDate.getDate(),
|
||||
this.selectedHour,
|
||||
this.selectedMinute
|
||||
);
|
||||
this.value = this.formatValueWithTimezone(this.selectedDate);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public handleTimezoneChange(e: Event): void {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
this.timezone = select.value;
|
||||
this.updateSelectedDateTime();
|
||||
}
|
||||
|
||||
private formatValueWithTimezone(date: Date): string {
|
||||
if (!this.enableTimezone) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Format the date with timezone offset
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: this.timezone,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const dateParts: any = {};
|
||||
parts.forEach(part => {
|
||||
dateParts[part.type] = part.value;
|
||||
});
|
||||
|
||||
// Create ISO-like format with timezone
|
||||
const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
|
||||
|
||||
// Get timezone offset
|
||||
const tzOffset = this.getTimezoneOffset(date, this.timezone);
|
||||
return `${isoString}${tzOffset}`;
|
||||
}
|
||||
|
||||
private getTimezoneOffset(date: Date, timezone: string): string {
|
||||
// Create a date in the target timezone
|
||||
const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||
|
||||
const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60);
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const minutes = Math.abs(offsetMinutes) % 60;
|
||||
const sign = offsetMinutes >= 0 ? '+' : '-';
|
||||
|
||||
return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
public handleKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.toggleCalendar();
|
||||
} else if (e.key === 'Escape' && this.isOpened) {
|
||||
e.preventDefault();
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
public clearValue(e: Event): void {
|
||||
e.stopPropagation();
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public handleManualInput(e: InputEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const inputValue = input.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
// Clear the value if input is empty
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = this.parseManualDate(inputValue);
|
||||
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
||||
// Update internal state without triggering re-render of input
|
||||
this.value = parsedDate.toISOString();
|
||||
this.selectedDate = parsedDate;
|
||||
this.viewDate = new Date(parsedDate);
|
||||
this.selectedHour = parsedDate.getHours();
|
||||
this.selectedMinute = parsedDate.getMinutes();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public handleInputBlur(e: FocusEvent): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const inputValue = input.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
this.value = '';
|
||||
this.selectedDate = null;
|
||||
this.changeSubject.next(this);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDate = this.parseManualDate(inputValue);
|
||||
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
||||
this.value = parsedDate.toISOString();
|
||||
this.selectedDate = parsedDate;
|
||||
this.viewDate = new Date(parsedDate);
|
||||
this.selectedHour = parsedDate.getHours();
|
||||
this.selectedMinute = parsedDate.getMinutes();
|
||||
this.changeSubject.next(this);
|
||||
// Update the input with formatted date
|
||||
input.value = this.formatDate(this.value);
|
||||
} else {
|
||||
// Revert to previous valid value on blur if parsing failed
|
||||
input.value = this.formatDate(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
private parseManualDate(input: string): Date | null {
|
||||
if (!input) return null;
|
||||
|
||||
// Split date and time parts if present
|
||||
const parts = input.split(' ');
|
||||
let datePart = parts[0];
|
||||
let timePart = parts[1] || '';
|
||||
|
||||
let parsedDate: Date | null = null;
|
||||
|
||||
// Try different date formats
|
||||
// Format 1: YYYY-MM-DD (ISO-like)
|
||||
const isoMatch = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (isoMatch) {
|
||||
const [_, year, month, day] = isoMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
|
||||
// Format 2: DD.MM.YYYY (European)
|
||||
if (!parsedDate) {
|
||||
const euMatch = datePart.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (euMatch) {
|
||||
const [_, day, month, year] = euMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
}
|
||||
|
||||
// Format 3: MM/DD/YYYY (US)
|
||||
if (!parsedDate) {
|
||||
const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (usMatch) {
|
||||
const [_, month, day, year] = usMatch;
|
||||
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
}
|
||||
}
|
||||
|
||||
// If no date was parsed, return null
|
||||
if (!parsedDate || isNaN(parsedDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse time if present (HH:MM format)
|
||||
if (timePart) {
|
||||
const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (timeMatch) {
|
||||
const [_, hours, minutes] = timeMatch;
|
||||
parsedDate.setHours(parseInt(hours));
|
||||
parsedDate.setMinutes(parseInt(minutes));
|
||||
}
|
||||
} else if (!this.enableTime) {
|
||||
// If time is not enabled and not provided, use current time
|
||||
const now = new Date();
|
||||
parsedDate.setHours(now.getHours());
|
||||
parsedDate.setMinutes(now.getMinutes());
|
||||
parsedDate.setSeconds(0);
|
||||
parsedDate.setMilliseconds(0);
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
if (value) {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
this.selectedDate = date;
|
||||
this.viewDate = new Date(date);
|
||||
this.selectedHour = date.getHours();
|
||||
this.selectedMinute = date.getMinutes();
|
||||
}
|
||||
} catch {
|
||||
// Invalid date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
410
ts_web/elements/00group-input/dees-input-datepicker/demo.ts
Normal file
410
ts_web/elements/00group-input/dees-input-datepicker/demo.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import './component.js';
|
||||
import type { DeesInputDatepicker } from './component.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 105, 242, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate basic date picker functionality
|
||||
const datePicker = elementArg.querySelector('dees-input-datepicker');
|
||||
|
||||
if (datePicker) {
|
||||
datePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
console.log('Basic date selected:', (event.target as DeesInputDatepicker).value);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Basic Date Picker'} .subtitle=${'Simple date selection without time'}>
|
||||
<dees-input-datepicker
|
||||
label="Select Date"
|
||||
description="Choose a date from the calendar"
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate date and time picker
|
||||
const dateTimePicker = elementArg.querySelector('dees-input-datepicker[label="Event Date & Time"]');
|
||||
const appointmentPicker = elementArg.querySelector('dees-input-datepicker[label="Appointment"]');
|
||||
|
||||
if (dateTimePicker) {
|
||||
dateTimePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
console.log('24h format datetime:', value);
|
||||
});
|
||||
}
|
||||
|
||||
if (appointmentPicker) {
|
||||
appointmentPicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
console.log('12h format datetime:', value);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Date and Time Selection'} .subtitle=${'Date pickers with time selection in different formats'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Date & Time"
|
||||
description="Select both date and time (24-hour format)"
|
||||
.enableTime=${true}
|
||||
timeFormat="24h"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Appointment"
|
||||
description="Date and time with AM/PM selector (15-minute increments)"
|
||||
.enableTime=${true}
|
||||
timeFormat="12h"
|
||||
.minuteIncrement=${15}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate timezone functionality
|
||||
const timezonePickers = elementArg.querySelectorAll('dees-input-datepicker');
|
||||
|
||||
timezonePickers.forEach((picker) => {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
console.log(`${target.label} value:`, target.value);
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
console.log(`${target.label} formatted:`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Timezone Support'} .subtitle=${'Date and time selection with timezone awareness'}>
|
||||
<dees-input-datepicker
|
||||
label="Meeting Time (with Timezone)"
|
||||
description="Select a date/time and timezone for the meeting"
|
||||
.enableTime=${true}
|
||||
.enableTimezone=${true}
|
||||
timeFormat="24h"
|
||||
timezone="America/New_York"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Global Event Schedule"
|
||||
description="Schedule an event across different timezones"
|
||||
.enableTime=${true}
|
||||
.enableTimezone=${true}
|
||||
timeFormat="12h"
|
||||
timezone="Europe/London"
|
||||
.minuteIncrement=${30}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate date constraints
|
||||
const futureDatePicker = elementArg.querySelector('dees-input-datepicker');
|
||||
|
||||
if (futureDatePicker) {
|
||||
// Show the min/max constraints in action
|
||||
futureDatePicker.addEventListener('change', (event: CustomEvent) => {
|
||||
const value = (event.target as DeesInputDatepicker).value;
|
||||
if (value) {
|
||||
const selectedDate = new Date(value);
|
||||
const today = new Date();
|
||||
const daysDiff = Math.floor((selectedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
console.log(`Selected date is ${daysDiff} days from today`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Date Range Constraints'} .subtitle=${'Limit selectable dates with min and max values'}>
|
||||
<dees-input-datepicker
|
||||
label="Future Date Only"
|
||||
description="Can only select dates from today to 90 days in the future"
|
||||
.minDate=${new Date().toISOString()}
|
||||
.maxDate=${new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate different date formats
|
||||
const formatters = {
|
||||
'DD/MM/YYYY': 'European',
|
||||
'MM/DD/YYYY': 'US',
|
||||
'YYYY-MM-DD': 'ISO'
|
||||
};
|
||||
|
||||
const datePickers = elementArg.querySelectorAll('dees-input-datepicker');
|
||||
datePickers.forEach((picker) => {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
// Log the formatted value that's displayed in the input
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
console.log(`${target.label} format:`, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Date Formats'} .subtitle=${'Different date display formats for various regions'}>
|
||||
<div class="date-group">
|
||||
<dees-input-datepicker
|
||||
label="European Format"
|
||||
dateFormat="DD/MM/YYYY"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="US Format"
|
||||
dateFormat="MM/DD/YYYY"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="ISO Format"
|
||||
dateFormat="YYYY-MM-DD"
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate required field validation
|
||||
const requiredPicker = elementArg.querySelector('dees-input-datepicker[required]');
|
||||
|
||||
if (requiredPicker) {
|
||||
// Monitor blur events for validation
|
||||
requiredPicker.addEventListener('blur', () => {
|
||||
const picker = requiredPicker as DeesInputDatepicker;
|
||||
const value = picker.getValue();
|
||||
if (!value) {
|
||||
console.log('Required date field is empty');
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Form States'} .subtitle=${'Required and disabled states'}>
|
||||
<dees-input-datepicker
|
||||
label="Birth Date"
|
||||
description="This field is required"
|
||||
.required=${true}
|
||||
placeholder="Select your birth date"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="Disabled Date"
|
||||
description="This field cannot be edited"
|
||||
.disabled=${true}
|
||||
.value=${new Date().toISOString()}
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate week start customization
|
||||
const usPicker = elementArg.querySelector('dees-input-datepicker[label="US Calendar"]');
|
||||
const euPicker = elementArg.querySelector('dees-input-datepicker[label="EU Calendar"]');
|
||||
|
||||
if (usPicker) {
|
||||
console.log('US Calendar starts on Sunday (0)');
|
||||
}
|
||||
if (euPicker) {
|
||||
console.log('EU Calendar starts on Monday (1)');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Calendar Customization'} .subtitle=${'Different week start days for various regions'}>
|
||||
<div class="date-group">
|
||||
<dees-input-datepicker
|
||||
label="US Calendar"
|
||||
description="Week starts on Sunday"
|
||||
.weekStartsOn=${0}
|
||||
></dees-input-datepicker>
|
||||
|
||||
<dees-input-datepicker
|
||||
label="EU Calendar"
|
||||
description="Week starts on Monday"
|
||||
.weekStartsOn=${1}
|
||||
></dees-input-datepicker>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Generate weekend dates for the current month
|
||||
const generateWeekends = () => {
|
||||
const weekends = [];
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
|
||||
// Get all weekends for current month
|
||||
const date = new Date(year, month, 1);
|
||||
while (date.getMonth() === month) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
weekends.push(new Date(date).toISOString());
|
||||
}
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return weekends;
|
||||
};
|
||||
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
if (picker) {
|
||||
picker.disabledDates = generateWeekends();
|
||||
console.log('Disabled weekend dates for current month');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Disabled Dates'} .subtitle=${'Calendar with specific dates disabled (weekends in current month)'}>
|
||||
<dees-input-datepicker
|
||||
label="Availability Calendar"
|
||||
description="Weekends are disabled for the current month"
|
||||
></dees-input-datepicker>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Generate sample events for the calendar
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
|
||||
const sampleEvents = [
|
||||
// Current week events
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`,
|
||||
title: "Team Meeting",
|
||||
type: "info" as const,
|
||||
count: 2
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 1).toString().padStart(2, '0')}`,
|
||||
title: "Project Deadline",
|
||||
type: "warning" as const
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 2).toString().padStart(2, '0')}`,
|
||||
title: "Release Day",
|
||||
type: "success" as const
|
||||
},
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 5).toString().padStart(2, '0')}`,
|
||||
title: "Urgent Fix Required",
|
||||
type: "error" as const
|
||||
},
|
||||
// Multiple events on one day
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 1).toString().padStart(2, '0')}-${(today.getDate() + 7).toString().padStart(2, '0')}`,
|
||||
title: "Multiple Events Today",
|
||||
type: "info" as const,
|
||||
count: 5
|
||||
},
|
||||
// Next month event
|
||||
{
|
||||
date: `${currentYear}-${(currentMonth + 2).toString().padStart(2, '0')}-15`,
|
||||
title: "Future Planning Session",
|
||||
type: "info" as const
|
||||
}
|
||||
];
|
||||
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
if (picker) {
|
||||
picker.events = sampleEvents;
|
||||
console.log('Calendar events loaded:', sampleEvents);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Calendar with Events'} .subtitle=${'Visual feedback for scheduled events'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Calendar"
|
||||
description="Days with colored dots have events. Hover to see details."
|
||||
></dees-input-datepicker>
|
||||
|
||||
<div class="demo-output" style="margin-top: 16px;">
|
||||
<strong>Event Legend:</strong><br>
|
||||
<span style="color: #0969da;">● Info</span> |
|
||||
<span style="color: #d29922;">● Warning</span> |
|
||||
<span style="color: #2ea043;">● Success</span> |
|
||||
<span style="color: #cf222e;">● Error</span><br>
|
||||
<em>Days with more than 3 events show a count badge</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Interactive event demonstration
|
||||
const picker = elementArg.querySelector('dees-input-datepicker');
|
||||
const output = elementArg.querySelector('#event-output');
|
||||
|
||||
if (picker && output) {
|
||||
picker.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputDatepicker;
|
||||
const value = target.value;
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
// Get the formatted value from the input element
|
||||
const input = target.shadowRoot?.querySelector('.date-input') as HTMLInputElement;
|
||||
const formattedValue = input?.value || 'N/A';
|
||||
output.innerHTML = `
|
||||
<strong>Event triggered!</strong><br>
|
||||
ISO Value: ${value}<br>
|
||||
Formatted: ${formattedValue}<br>
|
||||
Date object: ${date.toLocaleString()}
|
||||
`;
|
||||
} else {
|
||||
output.innerHTML = '<em>Date cleared</em>';
|
||||
}
|
||||
});
|
||||
|
||||
picker.addEventListener('blur', () => {
|
||||
console.log('Datepicker lost focus');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Event Handling'} .subtitle=${'Interactive demonstration of change events'}>
|
||||
<dees-input-datepicker
|
||||
label="Event Demo"
|
||||
description="Select a date to see the event details"
|
||||
></dees-input-datepicker>
|
||||
|
||||
<div id="event-output" class="demo-output">
|
||||
<em>Select a date to see event details...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
514
ts_web/elements/00group-input/dees-input-datepicker/styles.ts
Normal file
514
ts_web/elements/00group-input/dees-input-datepicker/styles.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
|
||||
export const datepickerStyles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.date-input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.date-input:hover:not(:disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.date-input:focus,
|
||||
.date-input.open {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
|
||||
.date-input:disabled {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Icon container using flexbox for better positioning */
|
||||
.icon-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-container > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.calendar-icon {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.clear-button:disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Calendar Popup Styles */
|
||||
.calendar-popup {
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)',
|
||||
'0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)'
|
||||
)};
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
margin-top: 4px;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.calendar-popup.top {
|
||||
bottom: calc(100% + 4px);
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.calendar-popup.bottom {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.calendar-popup.show {
|
||||
pointer-events: all;
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Calendar Header */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.month-year-display {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.nav-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
/* Weekday headers */
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
padding: 0 0 8px 0;
|
||||
}
|
||||
|
||||
/* Days grid */
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
border: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.day:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day.disabled {
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Event indicators */
|
||||
.day.has-event {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.event-dot.info {
|
||||
background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')};
|
||||
}
|
||||
|
||||
.event-dot.warning {
|
||||
background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')};
|
||||
}
|
||||
|
||||
.event-dot.success {
|
||||
background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')};
|
||||
}
|
||||
|
||||
.event-dot.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
|
||||
}
|
||||
|
||||
.event-count {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Tooltip for event details */
|
||||
.event-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')};
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.event-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.day.has-event:hover .event-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Time selector */
|
||||
.time-selector {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.time-selector-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.time-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
width: 65px;
|
||||
height: 36px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.time-input:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.time-input:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.am-pm-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.am-pm-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.am-pm-button.selected {
|
||||
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.am-pm-button:hover:not(.selected) {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.today-button {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
}
|
||||
|
||||
.today-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.today-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
}
|
||||
|
||||
.clear-button:active {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.2)', 'hsl(0 62.8% 30.6% / 0.2)')};
|
||||
}
|
||||
|
||||
/* Timezone selector */
|
||||
.timezone-selector {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
}
|
||||
|
||||
.timezone-selector-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
|
||||
}
|
||||
|
||||
.timezone-select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
|
||||
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timezone-select:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
|
||||
}
|
||||
|
||||
.timezone-select:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
179
ts_web/elements/00group-input/dees-input-datepicker/template.ts
Normal file
179
ts_web/elements/00group-input/dees-input-datepicker/template.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesInputDatepicker } from './component.js';
|
||||
|
||||
export const renderDatepicker = (component: DeesInputDatepicker): TemplateResult => {
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const weekDays = component.weekStartsOn === 1
|
||||
? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
const days = component.getDaysInMonth();
|
||||
const isAM = component.selectedHour < 12;
|
||||
const timezones = component.getTimezones();
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${component.label} .description=${component.description} .required=${component.required}></dees-label>
|
||||
<div class="input-container">
|
||||
<input
|
||||
type="text"
|
||||
class="date-input ${component.isOpened ? 'open' : ''}"
|
||||
.value=${component.formatDate(component.value)}
|
||||
.placeholder=${component.placeholder}
|
||||
?disabled=${component.disabled}
|
||||
@click=${component.toggleCalendar}
|
||||
@keydown=${component.handleKeydown}
|
||||
@input=${component.handleManualInput}
|
||||
@blur=${component.handleInputBlur}
|
||||
style="padding-right: ${component.value ? '64px' : '40px'}"
|
||||
/>
|
||||
<div class="icon-container">
|
||||
${component.value && !component.disabled ? html`
|
||||
<button class="clear-button" @click=${component.clearValue} title="Clear">
|
||||
<dees-icon icon="lucide:x" iconSize="14"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
<dees-icon class="calendar-icon" icon="lucide:calendar" iconSize="16"></dees-icon>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Popup -->
|
||||
<div class="calendar-popup ${component.isOpened ? 'show' : ''} ${component.opensToTop ? 'top' : 'bottom'}">
|
||||
<!-- Month/Year Navigation -->
|
||||
<div class="calendar-header">
|
||||
<button class="nav-button" @click=${component.previousMonth}>
|
||||
<dees-icon icon="lucide:chevronLeft" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
<div class="month-year-display">
|
||||
${monthNames[component.viewDate.getMonth()]} ${component.viewDate.getFullYear()}
|
||||
</div>
|
||||
<button class="nav-button" @click=${component.nextMonth}>
|
||||
<dees-icon icon="lucide:chevronRight" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
<div class="weekdays">
|
||||
${weekDays.map(day => html`<div class="weekday">${day}</div>`)}
|
||||
</div>
|
||||
|
||||
<!-- Days Grid -->
|
||||
<div class="days-grid">
|
||||
${days.map(day => {
|
||||
const isToday = component.isToday(day);
|
||||
const isSelected = component.isSelected(day);
|
||||
const isOtherMonth = day.getMonth() !== component.viewDate.getMonth();
|
||||
const isDisabled = component.isDisabled(day);
|
||||
const dayEvents = component.getEventsForDate(day);
|
||||
const hasEvents = dayEvents.length > 0;
|
||||
const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}"
|
||||
@click=${() => !isDisabled && component.selectDate(day)}
|
||||
>
|
||||
${day.getDate()}
|
||||
${hasEvents ? html`
|
||||
${totalEventCount > 3 ? html`
|
||||
<div class="event-count">${totalEventCount}</div>
|
||||
` : html`
|
||||
<div class="event-indicator">
|
||||
${dayEvents.slice(0, 3).map(event => html`
|
||||
<div class="event-dot ${event.type || 'info'}"></div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
${dayEvents[0].title ? html`
|
||||
<div class="event-tooltip">
|
||||
${dayEvents[0].title}
|
||||
${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Time Selector -->
|
||||
${component.enableTime ? html`
|
||||
<div class="time-selector">
|
||||
<div class="time-selector-title">Time</div>
|
||||
<div class="time-inputs">
|
||||
<input
|
||||
type="number"
|
||||
class="time-input"
|
||||
.value=${component.timeFormat === '12h'
|
||||
? (component.selectedHour === 0 ? 12 : component.selectedHour > 12 ? component.selectedHour - 12 : component.selectedHour).toString().padStart(2, '0')
|
||||
: component.selectedHour.toString().padStart(2, '0')}
|
||||
@input=${(e: InputEvent) => component.handleHourInput(e)}
|
||||
min="${component.timeFormat === '12h' ? 1 : 0}"
|
||||
max="${component.timeFormat === '12h' ? 12 : 23}"
|
||||
/>
|
||||
<span class="time-separator">:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="time-input"
|
||||
.value=${component.selectedMinute.toString().padStart(2, '0')}
|
||||
@input=${(e: InputEvent) => component.handleMinuteInput(e)}
|
||||
min="0"
|
||||
max="59"
|
||||
step="${component.minuteIncrement || 1}"
|
||||
/>
|
||||
${component.timeFormat === '12h' ? html`
|
||||
<div class="am-pm-selector">
|
||||
<button
|
||||
class="am-pm-button ${isAM ? 'selected' : ''}"
|
||||
@click=${() => component.setAMPM('am')}
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
class="am-pm-button ${!isAM ? 'selected' : ''}"
|
||||
@click=${() => component.setAMPM('pm')}
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Timezone Selector -->
|
||||
${component.enableTimezone ? html`
|
||||
<div class="timezone-selector">
|
||||
<div class="timezone-selector-title">Timezone</div>
|
||||
<select
|
||||
class="timezone-select"
|
||||
.value=${component.timezone}
|
||||
@change=${(e: Event) => component.handleTimezoneChange(e)}
|
||||
>
|
||||
${timezones.map(tz => html`
|
||||
<option value="${tz.value}" ?selected=${tz.value === component.timezone}>
|
||||
${tz.label}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="calendar-actions">
|
||||
<button class="action-button today-button" @click=${component.selectToday}>
|
||||
Today
|
||||
</button>
|
||||
<button class="action-button clear-button" @click=${component.clear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface IDateEvent {
|
||||
date: string; // ISO date string (YYYY-MM-DD)
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: 'info' | 'warning' | 'success' | 'error';
|
||||
count?: number; // Number of events on this day
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import '../../00group-form/dees-form/dees-form.js';
|
||||
import '../../00group-form/dees-form-submit/dees-form-submit.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate programmatic interaction with basic dropdowns
|
||||
const countryDropdown = elementArg.querySelector('dees-input-dropdown[label="Select Country"]');
|
||||
const roleDropdown = elementArg.querySelector('dees-input-dropdown[label="Select Role"]');
|
||||
|
||||
// Log when country changes
|
||||
if (countryDropdown) {
|
||||
countryDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Country selected:', event.detail);
|
||||
});
|
||||
}
|
||||
|
||||
// Log when role changes
|
||||
if (roleDropdown) {
|
||||
roleDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Role selected:', event.detail);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'1. Basic Dropdowns'} .subtitle=${'Standard dropdown with search functionality and various options'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Select Country'}
|
||||
.options=${[
|
||||
{ option: 'United States', key: 'us' },
|
||||
{ option: 'Canada', key: 'ca' },
|
||||
{ option: 'Germany', key: 'de' },
|
||||
{ option: 'France', key: 'fr' },
|
||||
{ option: 'United Kingdom', key: 'uk' },
|
||||
{ option: 'Australia', key: 'au' },
|
||||
{ option: 'Japan', key: 'jp' },
|
||||
{ option: 'Brazil', key: 'br' }
|
||||
]}
|
||||
.selectedOption=${{ option: 'United States', key: 'us' }}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Select Role'}
|
||||
.options=${[
|
||||
{ option: 'Administrator', key: 'admin' },
|
||||
{ option: 'Editor', key: 'editor' },
|
||||
{ option: 'Viewer', key: 'viewer' },
|
||||
{ option: 'Guest', key: 'guest' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate simpler dropdown without search
|
||||
const priorityDropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
|
||||
if (priorityDropdown) {
|
||||
priorityDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log(`Priority changed to: ${event.detail.option}`);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'2. Without Search'} .subtitle=${'Dropdown with search functionality disabled for simpler selection'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Priority Level'}
|
||||
.enableSearch=${false}
|
||||
.options=${[
|
||||
{ option: 'High', key: 'high' },
|
||||
{ option: 'Medium', key: 'medium' },
|
||||
{ option: 'Low', key: 'low' }
|
||||
]}
|
||||
.selectedOption=${{ option: 'Medium', key: 'medium' }}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate horizontal layout with multiple dropdowns
|
||||
const dropdowns = elementArg.querySelectorAll('dees-input-dropdown');
|
||||
|
||||
// Log all changes from horizontal dropdowns
|
||||
dropdowns.forEach((dropdown) => {
|
||||
dropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
const label = dropdown.getAttribute('label');
|
||||
console.log(`${label}: ${event.detail.option}`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'3. Horizontal Layout'} .subtitle=${'Multiple dropdowns in a horizontal layout for compact forms'}>
|
||||
<div class="horizontal-group">
|
||||
<dees-input-dropdown
|
||||
.label=${'Department'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.options=${[
|
||||
{ option: 'Engineering', key: 'eng' },
|
||||
{ option: 'Design', key: 'design' },
|
||||
{ option: 'Marketing', key: 'marketing' },
|
||||
{ option: 'Sales', key: 'sales' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Team Size'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.enableSearch=${false}
|
||||
.options=${[
|
||||
{ option: '1-5', key: 'small' },
|
||||
{ option: '6-20', key: 'medium' },
|
||||
{ option: '21-50', key: 'large' },
|
||||
{ option: '50+', key: 'xlarge' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Location'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.options=${[
|
||||
{ option: 'Remote', key: 'remote' },
|
||||
{ option: 'On-site', key: 'onsite' },
|
||||
{ option: 'Hybrid', key: 'hybrid' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate state handling
|
||||
const requiredDropdown = elementArg.querySelector('dees-input-dropdown[required]');
|
||||
|
||||
if (requiredDropdown) {
|
||||
// Show validation state changes
|
||||
requiredDropdown.addEventListener('blur', () => {
|
||||
console.log('Required dropdown lost focus');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'4. States'} .subtitle=${'Different states and configurations'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Required Field'}
|
||||
.required=${true}
|
||||
.options=${[
|
||||
{ option: 'Option A', key: 'a' },
|
||||
{ option: 'Option B', key: 'b' },
|
||||
{ option: 'Option C', key: 'c' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Disabled Dropdown'}
|
||||
.disabled=${true}
|
||||
.options=${[
|
||||
{ option: 'Cannot Select', key: 'disabled' }
|
||||
]}
|
||||
.selectedOption=${{ option: 'Cannot Select', key: 'disabled' }}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<div class="spacer">
|
||||
(Spacer to test dropdown positioning)
|
||||
</div>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// This dropdown demonstrates automatic positioning
|
||||
const dropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
|
||||
if (dropdown) {
|
||||
dropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
console.log('Bottom dropdown selected:', event.detail);
|
||||
});
|
||||
|
||||
// Note: The dropdown automatically detects available space
|
||||
// and opens upward when near the bottom of the viewport
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'5. Bottom Positioning'} .subtitle=${'Dropdown that opens upward when near bottom of viewport'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Opens Upward'}
|
||||
.options=${[
|
||||
{ option: 'First Option', key: 'first' },
|
||||
{ option: 'Second Option', key: 'second' },
|
||||
{ option: 'Third Option', key: 'third' },
|
||||
{ option: 'Fourth Option', key: 'fourth' },
|
||||
{ option: 'Fifth Option', key: 'fifth' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Setup the interactive payload display
|
||||
const dropdown = elementArg.querySelector('dees-input-dropdown');
|
||||
const output = elementArg.querySelector('#selection-output');
|
||||
|
||||
if (dropdown && output) {
|
||||
// Initialize output
|
||||
output.innerHTML = '<em>Select a product to see details...</em>';
|
||||
|
||||
// Handle dropdown changes
|
||||
dropdown.addEventListener('change', (event: CustomEvent) => {
|
||||
if (event.detail.value) {
|
||||
output.innerHTML = `
|
||||
<strong>Selected:</strong> ${event.detail.value.option}<br>
|
||||
<strong>Key:</strong> ${event.detail.value.key}<br>
|
||||
<strong>Price:</strong> $${event.detail.value.payload?.price || 'N/A'}<br>
|
||||
<strong>Features:</strong> ${event.detail.value.payload?.features?.join(', ') || 'N/A'}
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'6. Event Handling & Payload'} .subtitle=${'Dropdown with payload data and change event handling'}>
|
||||
<dees-input-dropdown
|
||||
.label=${'Select Product'}
|
||||
.options=${[
|
||||
{ option: 'Basic Plan', key: 'basic', payload: { price: 9.99, features: ['Feature A'] } },
|
||||
{ option: 'Pro Plan', key: 'pro', payload: { price: 19.99, features: ['Feature A', 'Feature B'] } },
|
||||
{ option: 'Enterprise Plan', key: 'enterprise', payload: { price: 49.99, features: ['Feature A', 'Feature B', 'Feature C'] } }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<div id="selection-output" style="margin-top: 16px; padding: 12px; background: rgba(0, 105, 242, 0.1); border-radius: 4px; font-size: 14px;"></div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate form integration and validation
|
||||
const form = elementArg.querySelector('dees-form');
|
||||
const projectTypeDropdown = elementArg.querySelector('dees-input-dropdown[key="projectType"]');
|
||||
const frameworkDropdown = elementArg.querySelector('dees-input-dropdown[key="framework"]');
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('formData', (event: CustomEvent) => {
|
||||
console.log('Form submitted with data:', event.detail.data);
|
||||
});
|
||||
}
|
||||
|
||||
if (projectTypeDropdown && frameworkDropdown) {
|
||||
// Filter frameworks based on project type
|
||||
projectTypeDropdown.addEventListener('selectedOption', (event: CustomEvent) => {
|
||||
const selectedType = event.detail.key;
|
||||
console.log(`Project type changed to: ${selectedType}`);
|
||||
|
||||
// In a real app, you could filter the framework options based on project type
|
||||
// For demo purposes, we just log the change
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Dropdown working within a form with validation'}>
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.label=${'Project Type'}
|
||||
.key=${'projectType'}
|
||||
.required=${true}
|
||||
.options=${[
|
||||
{ option: 'Web Application', key: 'web' },
|
||||
{ option: 'Mobile Application', key: 'mobile' },
|
||||
{ option: 'Desktop Application', key: 'desktop' },
|
||||
{ option: 'API Service', key: 'api' }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-input-dropdown
|
||||
.label=${'Development Framework'}
|
||||
.key=${'framework'}
|
||||
.required=${true}
|
||||
.options=${[
|
||||
{ option: 'React', key: 'react', payload: { type: 'web' } },
|
||||
{ option: 'Vue.js', key: 'vue', payload: { type: 'web' } },
|
||||
{ option: 'Angular', key: 'angular', payload: { type: 'web' } },
|
||||
{ option: 'React Native', key: 'react-native', payload: { type: 'mobile' } },
|
||||
{ option: 'Flutter', key: 'flutter', payload: { type: 'mobile' } },
|
||||
{ option: 'Electron', key: 'electron', payload: { type: 'desktop' } }
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
|
||||
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`
|
||||
@@ -0,0 +1,466 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { demoFunc } from './dees-input-dropdown.demo.js';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-dropdown': DeesInputDropdown;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-dropdown')
|
||||
export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
|
||||
@property()
|
||||
accessor options: { option: string; key: string; payload?: any }[] = [];
|
||||
|
||||
@property()
|
||||
accessor selectedOption: { option: string; key: string; payload?: any } = null;
|
||||
|
||||
// Add value property for form compatibility
|
||||
public get value() {
|
||||
return this.selectedOption;
|
||||
}
|
||||
|
||||
public set value(val: { option: string; key: string; payload?: any }) {
|
||||
this.selectedOption = val;
|
||||
}
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor enableSearch: boolean = true;
|
||||
|
||||
@state()
|
||||
accessor opensToTop: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor filteredOptions: { option: string; key: string; payload?: any }[] = [];
|
||||
|
||||
@state()
|
||||
accessor highlightedIndex: number = 0;
|
||||
|
||||
@state()
|
||||
accessor isOpened = false;
|
||||
|
||||
@state()
|
||||
accessor searchValue: string = '';
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
font-family: ${cssGeistFontFamily};
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selectedBox {
|
||||
user-select: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 38px;
|
||||
padding: 0 40px 0 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectedBox:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.selectedBox:focus-visible {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.selectedBox.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown arrow */
|
||||
.selectedBox::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.selectedBox.open::after {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
.selectionBox {
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
|
||||
min-height: 40px;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
margin-top: 4px;
|
||||
z-index: 50;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.selectionBox.top {
|
||||
bottom: calc(100% + 4px);
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
|
||||
.selectionBox.bottom {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.selectionBox.show {
|
||||
pointer-events: all;
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Options container */
|
||||
.options-container {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Options */
|
||||
.option {
|
||||
transition: all 0.15s ease;
|
||||
line-height: 32px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.option.highlighted {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
/* No options message */
|
||||
.no-options {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search {
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search.bottom {
|
||||
border-bottom: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin-bottom: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.search input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.options-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.options-container::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||
<div class="maincontainer">
|
||||
<div
|
||||
class="selectedBox ${this.isOpened ? 'open' : ''} ${this.disabled ? 'disabled' : ''}"
|
||||
@click="${() => !this.disabled && this.toggleSelectionBox()}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${this.handleSelectedBoxKeydown}"
|
||||
>
|
||||
${this.selectedOption?.option || 'Select an option'}
|
||||
</div>
|
||||
<div class="selectionBox ${this.isOpened ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}">
|
||||
${this.enableSearch
|
||||
? html`
|
||||
<div class="search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search options..."
|
||||
.value="${this.searchValue}"
|
||||
@input="${this.handleSearch}"
|
||||
@click="${(e: Event) => e.stopPropagation()}"
|
||||
@keydown="${this.handleSearchKeydown}"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div class="options-container">
|
||||
${this.filteredOptions.length === 0
|
||||
? html`<div class="no-options">No options found</div>`
|
||||
: this.filteredOptions.map((option, index) => {
|
||||
const isHighlighted = this.highlightedIndex === index;
|
||||
return html`
|
||||
<div
|
||||
class="option ${isHighlighted ? 'highlighted' : ''}"
|
||||
@click="${() => this.updateSelection(option)}"
|
||||
@mouseenter="${() => this.highlightedIndex = index}"
|
||||
>
|
||||
${option.option}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.selectedOption = this.selectedOption || null;
|
||||
this.filteredOptions = this.options;
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('options')) {
|
||||
this.filteredOptions = this.options;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
|
||||
this.selectedOption = selectedOption;
|
||||
this.isOpened = false;
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
this.highlightedIndex = 0;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('selectedOption', {
|
||||
detail: selectedOption,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private handleClickOutside = (event: MouseEvent) => {
|
||||
const path = event.composedPath();
|
||||
if (!path.includes(this)) {
|
||||
this.isOpened = false;
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
};
|
||||
|
||||
public async toggleSelectionBox() {
|
||||
this.isOpened = !this.isOpened;
|
||||
|
||||
if (this.isOpened) {
|
||||
// Check available space and set position
|
||||
const selectedBox = this.shadowRoot.querySelector('.selectedBox') as HTMLElement;
|
||||
const rect = selectedBox.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
|
||||
// Determine if we should open upwards
|
||||
this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
|
||||
|
||||
// Focus search input if present
|
||||
await this.updateComplete;
|
||||
const searchInput = this.shadowRoot.querySelector('.search input') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
|
||||
// Add click outside listener
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
}, 0);
|
||||
} else {
|
||||
// Cleanup
|
||||
this.searchValue = '';
|
||||
this.filteredOptions = this.options;
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearch(event: Event): void {
|
||||
const searchTerm = (event.target as HTMLInputElement).value;
|
||||
this.searchValue = searchTerm;
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
this.filteredOptions = this.options.filter((option) =>
|
||||
option.option.toLowerCase().includes(searchLower)
|
||||
);
|
||||
this.highlightedIndex = 0;
|
||||
}
|
||||
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
const key = event.key;
|
||||
const maxIndex = this.filteredOptions.length - 1;
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
|
||||
} else if (key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
|
||||
} else if (key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (this.filteredOptions[this.highlightedIndex]) {
|
||||
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
|
||||
}
|
||||
} else if (key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearchKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
this.handleKeyDown(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectedBoxKeydown(event: KeyboardEvent) {
|
||||
if (this.disabled) return;
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleSelectionBox();
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!this.isOpened) {
|
||||
this.toggleSelectionBox();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (this.isOpened) {
|
||||
this.isOpened = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): { option: string; key: string; payload?: any } {
|
||||
return this.selectedOption;
|
||||
}
|
||||
|
||||
public setValue(value: { option: string; key: string; payload?: any }): void {
|
||||
this.selectedOption = value;
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-dropdown.js';
|
||||
619
ts_web/elements/00group-input/dees-input-fileupload/component.ts
Normal file
619
ts_web/elements/00group-input/dees-input-fileupload/component.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { fileuploadStyles } from './styles.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import '../../dees-label/dees-label.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-fileupload': DeesInputFileupload;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-fileupload')
|
||||
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: File[] = [];
|
||||
|
||||
@state()
|
||||
accessor state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
|
||||
|
||||
@state()
|
||||
accessor isLoading: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor buttonText: string = 'Select files';
|
||||
|
||||
@property({ type: String })
|
||||
accessor accept: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor multiple: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxSize: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxFiles: number = 0; // 0 means no limit
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
accessor validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
||||
|
||||
accessor validationMessage: string = '';
|
||||
|
||||
private previewUrlMap: WeakMap<File, string> = new WeakMap();
|
||||
private dropArea: HTMLElement | null = null;
|
||||
|
||||
public static styles = fileuploadStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
const acceptedSummary = this.getAcceptedSummary();
|
||||
const metaEntries: string[] = [
|
||||
this.multiple ? 'Multiple files supported' : 'Single file only',
|
||||
this.maxSize > 0 ? `Max ${this.formatFileSize(this.maxSize)}` : 'No size limit',
|
||||
];
|
||||
|
||||
if (acceptedSummary) {
|
||||
metaEntries.push(`Accepts ${acceptedSummary}`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label
|
||||
.label=${this.label}
|
||||
.description=${this.description}
|
||||
.required=${this.required}
|
||||
></dees-label>
|
||||
<div
|
||||
class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}"
|
||||
role="button"
|
||||
tabindex=${this.disabled ? -1 : 0}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-label=${`Select files${acceptedSummary ? ` (${acceptedSummary})` : ''}`}
|
||||
@click=${this.handleDropzoneClick}
|
||||
@keydown=${this.handleDropzoneKeydown}
|
||||
>
|
||||
<input
|
||||
class="file-input"
|
||||
style="position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; top: 0; left: 0; overflow: hidden;"
|
||||
type="file"
|
||||
?multiple=${this.multiple}
|
||||
accept=${this.accept || ''}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this.handleFileInputChange}
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div class="dropzone__body">
|
||||
<div class="dropzone__icon">
|
||||
${this.isLoading
|
||||
? html`<span class="dropzone__loader" aria-hidden="true"></span>`
|
||||
: html`<dees-icon icon="lucide:FolderOpen"></dees-icon>`}
|
||||
</div>
|
||||
<div class="dropzone__content">
|
||||
<span class="dropzone__headline">${this.buttonText || 'Select files'}</span>
|
||||
<span class="dropzone__subline">
|
||||
Drag and drop files here or
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone__browse"
|
||||
@click=${this.handleBrowseClick}
|
||||
?disabled=${this.disabled}
|
||||
>
|
||||
browse
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropzone__meta">
|
||||
${metaEntries.map((entry) => html`<span>${entry}</span>`)}
|
||||
</div>
|
||||
${this.renderFileList()}
|
||||
</div>
|
||||
${this.validationMessage
|
||||
? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileList(): TemplateResult {
|
||||
if (this.value.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="file-list">
|
||||
<div class="file-list__header">
|
||||
<span>${this.value.length} file${this.value.length === 1 ? '' : 's'} selected</span>
|
||||
${this.value.length > 0
|
||||
? html`<button type="button" class="file-list__clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="file-list__items">
|
||||
${this.value.map((file) => this.renderFileRow(file))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileRow(file: File): TemplateResult {
|
||||
const fileType = this.getFileType(file);
|
||||
const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null;
|
||||
|
||||
return html`
|
||||
<div class="file-row ${fileType}-file">
|
||||
<div class="file-thumb" aria-hidden="true">
|
||||
${previewUrl
|
||||
? html`<img class="thumb-image" src=${previewUrl} alt=${`Preview of ${file.name}`}>`
|
||||
: html`<dees-icon icon=${this.getFileIcon(file)}></dees-icon>`}
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
<div class="file-name" title=${file.name}>${file.name}</div>
|
||||
<div class="file-details">
|
||||
<span class="file-size">${this.formatFileSize(file.size)}</span>
|
||||
${fileType !== 'file' ? html`<span class="file-type">${fileType}</span>` : html``}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
@click=${() => this.removeFile(file)}
|
||||
aria-label=${`Remove ${file.name}`}
|
||||
>
|
||||
<dees-icon icon="lucide:X"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleFileInputChange = async (event: Event) => {
|
||||
this.isLoading = false;
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await this.addFiles(files);
|
||||
}
|
||||
target.value = '';
|
||||
};
|
||||
|
||||
private handleDropzoneClick = (event: MouseEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
// Don't open file selector if clicking on the browse button or file list
|
||||
if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) {
|
||||
return;
|
||||
}
|
||||
this.openFileSelector();
|
||||
};
|
||||
|
||||
private handleBrowseClick = (event: MouseEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation(); // Stop propagation to prevent double trigger
|
||||
this.openFileSelector();
|
||||
};
|
||||
|
||||
private handleDropzoneKeydown = (event: KeyboardEvent) => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.openFileSelector();
|
||||
}
|
||||
};
|
||||
|
||||
private handleClearAll = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
this.clearAll();
|
||||
};
|
||||
|
||||
private handleDragEvent = async (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'dragenter' || event.type === 'dragover') {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
this.state = 'dragOver';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'dragleave') {
|
||||
if (!this.dropArea) {
|
||||
this.state = 'idle';
|
||||
return;
|
||||
}
|
||||
const rect = this.dropArea.getBoundingClientRect();
|
||||
const { clientX = 0, clientY = 0 } = event;
|
||||
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
|
||||
this.state = 'idle';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'drop') {
|
||||
this.state = 'idle';
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await this.addFiles(files);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private attachDropListeners(): void {
|
||||
if (!this.dropArea) {
|
||||
return;
|
||||
}
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
||||
this.dropArea!.addEventListener(eventName, this.handleDragEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private detachDropListeners(): void {
|
||||
if (!this.dropArea) {
|
||||
return;
|
||||
}
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
||||
this.dropArea!.removeEventListener(eventName, this.handleDragEvent);
|
||||
});
|
||||
}
|
||||
|
||||
private rebindInteractiveElements(): void {
|
||||
const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null;
|
||||
|
||||
if (newDropArea !== this.dropArea) {
|
||||
this.detachDropListeners();
|
||||
this.dropArea = newDropArea;
|
||||
this.attachDropListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public formatFileSize(bytes: number): string {
|
||||
const units = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = bytes / Math.pow(1024, exponent);
|
||||
return `${Math.round(size * 100) / 100} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
public getFileType(file: File): string {
|
||||
const type = file.type.toLowerCase();
|
||||
if (type.startsWith('image/')) return 'image';
|
||||
if (type === 'application/pdf') return 'pdf';
|
||||
if (type.includes('word') || type.includes('document')) return 'doc';
|
||||
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
|
||||
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
|
||||
if (type.startsWith('video/')) return 'video';
|
||||
if (type.startsWith('audio/')) return 'audio';
|
||||
if (type.includes('zip') || type.includes('compressed')) return 'archive';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
public getFileIcon(file: File): string {
|
||||
const fileType = this.getFileType(file);
|
||||
const iconMap: Record<string, string> = {
|
||||
image: 'lucide:FileImage',
|
||||
pdf: 'lucide:FileText',
|
||||
doc: 'lucide:FileText',
|
||||
spreadsheet: 'lucide:FileSpreadsheet',
|
||||
presentation: 'lucide:FileBarChart',
|
||||
video: 'lucide:FileVideo',
|
||||
audio: 'lucide:FileAudio',
|
||||
archive: 'lucide:FileArchive',
|
||||
file: 'lucide:File',
|
||||
};
|
||||
return iconMap[fileType] ?? 'lucide:File';
|
||||
}
|
||||
|
||||
public canShowPreview(file: File): boolean {
|
||||
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024;
|
||||
}
|
||||
|
||||
private validateFile(file: File): boolean {
|
||||
if (this.maxSize > 0 && file.size > this.maxSize) {
|
||||
this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.accept) {
|
||||
const acceptedTypes = this.accept
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
if (acceptedTypes.length > 0) {
|
||||
let isAccepted = false;
|
||||
for (const acceptType of acceptedTypes) {
|
||||
if (acceptType.startsWith('.')) {
|
||||
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (acceptType.endsWith('/*')) {
|
||||
const prefix = acceptType.slice(0, -2);
|
||||
if (file.type.startsWith(prefix)) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
} else if (file.type === acceptType) {
|
||||
isAccepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAccepted) {
|
||||
this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`;
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPreviewUrl(file: File): string {
|
||||
let url = this.previewUrlMap.get(file);
|
||||
if (!url) {
|
||||
url = URL.createObjectURL(file);
|
||||
this.previewUrlMap.set(file, url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private releasePreview(file: File): void {
|
||||
const url = this.previewUrlMap.get(file);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
this.previewUrlMap.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
private getAcceptedSummary(): string | null {
|
||||
if (!this.accept) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatted = Array.from(
|
||||
new Set(
|
||||
this.accept
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token) => this.formatAcceptToken(token))
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
if (formatted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (formatted.length === 1) {
|
||||
return formatted[0];
|
||||
}
|
||||
|
||||
if (formatted.length === 2) {
|
||||
return `${formatted[0]}, ${formatted[1]}`;
|
||||
}
|
||||
|
||||
return `${formatted.slice(0, 2).join(', ')}…`;
|
||||
}
|
||||
|
||||
private formatAcceptToken(token: string): string {
|
||||
if (token === '*/*') {
|
||||
return 'All files';
|
||||
}
|
||||
|
||||
if (token.endsWith('/*')) {
|
||||
const family = token.split('/')[0];
|
||||
if (!family) {
|
||||
return 'All files';
|
||||
}
|
||||
return `${family.charAt(0).toUpperCase()}${family.slice(1)} files`;
|
||||
}
|
||||
|
||||
if (token.startsWith('.')) {
|
||||
return token.slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
if (token.includes('pdf')) return 'PDF';
|
||||
if (token.includes('zip')) return 'ZIP';
|
||||
if (token.includes('json')) return 'JSON';
|
||||
if (token.includes('msword')) return 'DOC';
|
||||
if (token.includes('wordprocessingml')) return 'DOCX';
|
||||
if (token.includes('excel')) return 'XLS';
|
||||
if (token.includes('presentation')) return 'PPT';
|
||||
|
||||
const segments = token.split('/');
|
||||
const lastSegment = segments.pop() ?? token;
|
||||
return lastSegment.toUpperCase();
|
||||
}
|
||||
|
||||
private attachLifecycleListeners(): void {
|
||||
this.rebindInteractiveElements();
|
||||
}
|
||||
|
||||
public firstUpdated(changedProperties: Map<string, unknown>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.attachLifecycleListeners();
|
||||
}
|
||||
|
||||
public updated(changedProperties: Map<string, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('value')) {
|
||||
void this.validate();
|
||||
}
|
||||
this.rebindInteractiveElements();
|
||||
}
|
||||
|
||||
public async disconnectedCallback(): Promise<void> {
|
||||
this.detachDropListeners();
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.previewUrlMap = new WeakMap();
|
||||
await super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public async openFileSelector() {
|
||||
if (this.disabled || this.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
// Ensure we have the latest reference to the file input
|
||||
const inputFile = this.shadowRoot?.querySelector('.file-input') as HTMLInputElement | null;
|
||||
|
||||
if (!inputFile) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
if (!inputFile.files || inputFile.files.length === 0) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
// Click the input to open file selector
|
||||
inputFile.click();
|
||||
}
|
||||
|
||||
public removeFile(file: File) {
|
||||
const index = this.value.indexOf(file);
|
||||
if (index > -1) {
|
||||
this.releasePreview(file);
|
||||
this.value.splice(index, 1);
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public clearAll() {
|
||||
const existingFiles = [...this.value];
|
||||
this.value = [];
|
||||
existingFiles.forEach((file) => this.releasePreview(file));
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
this.changeSubject.next(this);
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
|
||||
public async updateValue(eventArg: Event) {
|
||||
const target = eventArg.target as HTMLInputElement;
|
||||
this.value = Array.from(target.files ?? []);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public setValue(value: File[]): void {
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.value = value;
|
||||
if (value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
} else {
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
}
|
||||
|
||||
public getValue(): File[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private async addFiles(files: File[]) {
|
||||
const filesToAdd: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (this.validateFile(file)) {
|
||||
filesToAdd.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToAdd.length === 0) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.maxFiles > 0) {
|
||||
const totalFiles = this.value.length + filesToAdd.length;
|
||||
if (totalFiles > this.maxFiles) {
|
||||
const allowedCount = this.maxFiles - this.value.length;
|
||||
if (allowedCount <= 0) {
|
||||
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
|
||||
this.validationState = 'invalid';
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
filesToAdd.splice(allowedCount);
|
||||
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
|
||||
this.validationState = 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.multiple && filesToAdd.length > 0) {
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.value = [filesToAdd[0]];
|
||||
} else {
|
||||
this.value.push(...filesToAdd);
|
||||
}
|
||||
|
||||
this.validationMessage = '';
|
||||
this.validationState = null;
|
||||
this.requestUpdate('value');
|
||||
await this.validate();
|
||||
this.changeSubject.next(this);
|
||||
this.isLoading = false;
|
||||
|
||||
if (this.value.length > 0) {
|
||||
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
|
||||
} else {
|
||||
this.buttonText = 'Select files';
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
this.validationMessage = '';
|
||||
|
||||
if (this.required && this.value.length === 0) {
|
||||
this.validationState = 'invalid';
|
||||
this.validationMessage = 'Please select at least one file';
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const file of this.value) {
|
||||
if (!this.validateFile(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.validationState = this.value.length > 0 ? 'valid' : null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
159
ts_web/elements/00group-input/dees-input-fileupload/demo.ts
Normal file
159
ts_web/elements/00group-input/dees-input-fileupload/demo.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { css, cssManager, html } from '@design.estate/dees-element';
|
||||
import './component.js';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding: 24px;
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.demo-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.demo-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(215 20% 26%)')};
|
||||
background: ${cssManager.bdTheme('hsl(213 100% 97%)', 'hsl(215 20% 12%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 25% 32%)', 'hsl(215 20% 82%)')};
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.demo-note strong {
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 45%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-shell">
|
||||
<dees-panel
|
||||
.title=${'Modern file uploader'}
|
||||
.subtitle=${'Shadcn-inspired layout with drag & drop, previews and validation'}
|
||||
>
|
||||
<div class="demo-grid demo-grid--two">
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Attachments'}
|
||||
.description=${'Upload supporting documents for your request'}
|
||||
.accept=${'image/*,.pdf,.zip'}
|
||||
.maxSize=${10 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Brand assets'}
|
||||
.description=${'Upload high-resolution imagery (JPG/PNG)'}
|
||||
.accept=${'image/jpeg,image/png'}
|
||||
.multiple=${false}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.buttonText=${'Select cover image'}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Audio uploads'}
|
||||
.description=${'Share podcast drafts (MP3/WAV, max 25MB each)'}
|
||||
.accept=${'audio/*'}
|
||||
.maxSize=${25 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Disabled example'}
|
||||
.description=${'Uploader is disabled while moderation is pending'}
|
||||
.disabled=${true}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel
|
||||
.title=${'Form integration'}
|
||||
.subtitle=${'Combine file uploads with the rest of the DEES form ecosystem'}
|
||||
>
|
||||
<div class="demo-grid">
|
||||
<dees-form>
|
||||
<div class="demo-stack">
|
||||
<dees-input-text
|
||||
.label=${'Project name'}
|
||||
.description=${'How should we refer to this project internally?'}
|
||||
.required=${true}
|
||||
.key=${'projectName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Contact email'}
|
||||
.inputType=${'email'}
|
||||
.required=${true}
|
||||
.key=${'contactEmail'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Statement of work'}
|
||||
.description=${'Upload a signed statement of work (PDF, max 15MB)'}
|
||||
.required=${true}
|
||||
.accept=${'application/pdf'}
|
||||
.maxSize=${15 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.key=${'sow'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Creative references'}
|
||||
.description=${'Optional. Upload up to five visual references'}
|
||||
.accept=${'image/*'}
|
||||
.maxFiles=${5}
|
||||
.maxSize=${8 * 1024 * 1024}
|
||||
.key=${'references'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.description=${'Add optional context for reviewers'}
|
||||
.inputType=${'textarea'}
|
||||
.key=${'notes'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Submit briefing'}></dees-form-submit>
|
||||
</div>
|
||||
</dees-form>
|
||||
|
||||
<div class="demo-note">
|
||||
<strong>Good to know:</strong>
|
||||
<ul>
|
||||
<li>Drag & drop highlights the dropzone and supports keyboard activation.</li>
|
||||
<li>Accepted file types are summarised automatically from the <code>accept</code> attribute.</li>
|
||||
<li>Image uploads show live previews generated via <code>URL.createObjectURL</code>.</li>
|
||||
<li>File size and file-count limits surface inline validation messages.</li>
|
||||
<li>The component stays compatible with <code>dees-form</code> value accessors.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
313
ts_web/elements/00group-input/dees-input-fileupload/styles.ts
Normal file
313
ts_web/elements/00group-input/dees-input-fileupload/styles.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
|
||||
export const fileuploadStyles = [
|
||||
cssManager.defaultStyles,
|
||||
...DeesInputBase.baseStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px dashed ${cssManager.bdTheme('hsl(215 16% 80%)', 'hsl(217 20% 25%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')};
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropzone:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(217 91% 60% / 0.5)', 'hsl(213 93% 68% / 0.4)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
}
|
||||
|
||||
.dropzone--active {
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
box-shadow: 0 12px 32px ${cssManager.bdTheme('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.35)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.06)', 'hsl(213 93% 68% / 0.12)')};
|
||||
}
|
||||
|
||||
.dropzone--has-files {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 11%)')};
|
||||
}
|
||||
|
||||
.dropzone--disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropzone__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dropzone__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.12)', 'hsl(213 93% 68% / 0.12)')};
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropzone__icon dees-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dropzone__loader {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid ${cssManager.bdTheme('rgba(15, 23, 42, 0.15)', 'rgba(255, 255, 255, 0.15)')};
|
||||
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
animation: loader-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.dropzone__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropzone__headline {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
}
|
||||
|
||||
.dropzone__subline {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.dropzone__browse {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dropzone__browse:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dropzone__browse:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dropzone__meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')};
|
||||
}
|
||||
|
||||
.dropzone__meta span {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(213 93% 18%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
|
||||
}
|
||||
|
||||
.file-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 68%)')};
|
||||
}
|
||||
|
||||
.file-list__clear {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-list__clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.5)', 'hsl(215 20% 16% / 0.5)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(213 27% 92%)', 'hsl(217 25% 26%)')};
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.8)', 'hsl(215 20% 16% / 0.8)')};
|
||||
}
|
||||
|
||||
.file-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-thumb dees-icon {
|
||||
font-size: 18px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')};
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 32% 28%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.15s ease, color 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 52%)', 'hsl(215 16% 68%)')};
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 50% / 0.08)', 'hsl(0 62% 32% / 0.15)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 46%)', 'hsl(0 70% 70%)')};
|
||||
}
|
||||
|
||||
.remove-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.remove-button dees-icon {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.payment-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Basic IBAN Input'} .subtitle=${'International Bank Account Number with automatic formatting'}>
|
||||
<dees-input-iban
|
||||
.label=${'Bank Account IBAN'}
|
||||
.description=${'Enter your International Bank Account Number'}
|
||||
></dees-input-iban>
|
||||
|
||||
<dees-input-iban
|
||||
.label=${'Verified IBAN'}
|
||||
.description=${'This IBAN has been verified'}
|
||||
.value=${'DE89370400440532013000'}
|
||||
></dees-input-iban>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Payment Information'} .subtitle=${'IBAN input with horizontal layout for payment forms'}>
|
||||
<div class="payment-group">
|
||||
<dees-input-text
|
||||
.label=${'Account Holder'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${'John Doe'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-iban
|
||||
.label=${'IBAN'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${'GB82WEST12345698765432'}
|
||||
></dees-input-iban>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Validation & States'} .subtitle=${'Required fields and disabled states'}>
|
||||
<dees-input-iban
|
||||
.label=${'Payment Account'}
|
||||
.description=${'Required for processing payments'}
|
||||
.required=${true}
|
||||
></dees-input-iban>
|
||||
|
||||
<dees-input-iban
|
||||
.label=${'Locked IBAN'}
|
||||
.description=${'This IBAN cannot be changed'}
|
||||
.value=${'FR1420041010050500013M02606'}
|
||||
.disabled=${true}
|
||||
></dees-input-iban>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Bank Transfer Form'} .subtitle=${'Complete form example with IBAN validation'}>
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'Recipient Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-iban .label=${'Recipient IBAN'} .required=${true}></dees-input-iban>
|
||||
<dees-input-text .label=${'Transfer Reference'} .description=${'Optional reference for the transfer'}></dees-input-text>
|
||||
<dees-input-text .label=${'Amount'} .inputType=${'number'} .required=${true}></dees-input-text>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
state,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import * as ibantools from 'ibantools';
|
||||
import { demoFunc } from './dees-input-iban.demo.js';
|
||||
|
||||
@customElement('dees-input-iban')
|
||||
export class DeesInputIban extends DeesInputBase<DeesInputIban> {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@state()
|
||||
accessor enteredString: string = '';
|
||||
|
||||
@state()
|
||||
accessor enteredIbanIsValid: boolean = false;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor value = '';
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
/* IBAN input specific styles can go here */
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label || 'IBAN'} .description=${this.description}></dees-label>
|
||||
<dees-input-text
|
||||
.value=${this.value}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.placeholder=${'DE89 3704 0044 0532 0130 00'}
|
||||
@input=${(eventArg: InputEvent) => {
|
||||
this.validateIban(eventArg);
|
||||
}}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
const deesInputText = this.shadowRoot.querySelector('dees-input-text') as any;
|
||||
if (deesInputText && deesInputText.changeSubject) {
|
||||
deesInputText.changeSubject.subscribe(() => {
|
||||
this.changeSubject.next(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async validateIban(eventArg: InputEvent): Promise<void> {
|
||||
const inputElement: HTMLInputElement = eventArg.target as HTMLInputElement;
|
||||
let enteredString = inputElement?.value;
|
||||
enteredString = enteredString || '';
|
||||
if (this.enteredString !== enteredString) {
|
||||
this.enteredString = ibantools.friendlyFormatIBAN(enteredString) || '';
|
||||
if (inputElement) {
|
||||
inputElement.value = this.enteredString;
|
||||
this.value = this.enteredString;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
this.enteredIbanIsValid = ibantools.isValidIBAN(this.enteredString.replace(/ /g, ''));
|
||||
const deesInputText = this.shadowRoot.querySelector('dees-input-text');
|
||||
deesInputText.validationText = `IBAN is valid: ${this.enteredIbanIsValid}`;
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.enteredString = ibantools.friendlyFormatIBAN(value) || '';
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-iban/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-iban/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-iban.js';
|
||||
@@ -0,0 +1,275 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import '../../00group-form/dees-form/dees-form.js';
|
||||
import '../dees-input-text/dees-input-text.js';
|
||||
import '../../00group-form/dees-form-submit/dees-form-submit.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.output-preview {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.output-preview {
|
||||
background: #2c2c2c;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-note {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-note {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic List Input'} .subtitle=${'Simple list management with add, edit, and delete'}>
|
||||
<dees-input-list
|
||||
.label=${'Shopping List'}
|
||||
.placeholder=${'Add item to your list...'}
|
||||
.value=${['Milk', 'Bread', 'Eggs', 'Cheese']}
|
||||
.description=${'Double-click to edit items, or use the edit button'}
|
||||
></dees-input-list>
|
||||
<div class="feature-note">
|
||||
💡 Double-click any item to quickly edit it inline
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. Sortable List'} .subtitle=${'Drag and drop to reorder items'}>
|
||||
<dees-input-list
|
||||
.label=${'Task Priority'}
|
||||
.placeholder=${'Add a task...'}
|
||||
.sortable=${true}
|
||||
.value=${[
|
||||
'Review pull requests',
|
||||
'Fix critical bug',
|
||||
'Update documentation',
|
||||
'Deploy to production',
|
||||
'Team standup meeting'
|
||||
]}
|
||||
.description=${'Drag items using the handle to reorder them'}
|
||||
></dees-input-list>
|
||||
<div class="feature-note">
|
||||
🔄 Drag the grip handle to reorder tasks by priority
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Validation & Constraints'} .subtitle=${'Lists with minimum/maximum items and duplicate prevention'}>
|
||||
<div class="grid-layout">
|
||||
<dees-input-list
|
||||
.label=${'Team Members (Min 2, Max 5)'}
|
||||
.placeholder=${'Add team member...'}
|
||||
.minItems=${2}
|
||||
.maxItems=${5}
|
||||
.value=${['Alice', 'Bob']}
|
||||
.required=${true}
|
||||
.description=${'Add 2-5 team members'}
|
||||
></dees-input-list>
|
||||
|
||||
<dees-input-list
|
||||
.label=${'Unique Tags (No Duplicates)'}
|
||||
.placeholder=${'Add unique tag...'}
|
||||
.allowDuplicates=${false}
|
||||
.value=${['frontend', 'backend', 'database']}
|
||||
.description=${'Duplicate items are not allowed'}
|
||||
></dees-input-list>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. Delete Confirmation'} .subtitle=${'Require confirmation before deleting items'}>
|
||||
<dees-input-list
|
||||
.label=${'Important Documents'}
|
||||
.placeholder=${'Add document name...'}
|
||||
.confirmDelete=${true}
|
||||
.value=${[
|
||||
'Contract_2024.pdf',
|
||||
'Financial_Report_Q3.xlsx',
|
||||
'Project_Proposal.docx',
|
||||
'Meeting_Notes.txt'
|
||||
]}
|
||||
.description=${'Deletion requires confirmation for safety'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only list display'}>
|
||||
<dees-input-list
|
||||
.label=${'System Defaults'}
|
||||
.value=${['Default Setting 1', 'Default Setting 2', 'Default Setting 3']}
|
||||
.disabled=${true}
|
||||
.description=${'These items cannot be modified'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'6. Form Integration'} .subtitle=${'List input working within a form context'}>
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.label=${'Recipe Name'}
|
||||
.placeholder=${'My Amazing Recipe'}
|
||||
.required=${true}
|
||||
.key=${'name'}
|
||||
></dees-input-text>
|
||||
|
||||
<div class="grid-layout">
|
||||
<dees-input-list
|
||||
.label=${'Ingredients'}
|
||||
.placeholder=${'Add ingredient...'}
|
||||
.required=${true}
|
||||
.minItems=${3}
|
||||
.key=${'ingredients'}
|
||||
.sortable=${true}
|
||||
.value=${[
|
||||
'2 cups flour',
|
||||
'1 cup sugar',
|
||||
'3 eggs'
|
||||
]}
|
||||
.description=${'Add at least 3 ingredients'}
|
||||
></dees-input-list>
|
||||
|
||||
<dees-input-list
|
||||
.label=${'Instructions'}
|
||||
.placeholder=${'Add instruction step...'}
|
||||
.required=${true}
|
||||
.minItems=${2}
|
||||
.key=${'instructions'}
|
||||
.sortable=${true}
|
||||
.value=${[
|
||||
'Preheat oven to 350°F',
|
||||
'Mix dry ingredients'
|
||||
]}
|
||||
.description=${'Add cooking instructions in order'}
|
||||
></dees-input-list>
|
||||
</div>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.inputType=${'textarea'}
|
||||
.placeholder=${'Any special notes or tips...'}
|
||||
.key=${'notes'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Save Recipe'}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Build your own feature list and see the data'}>
|
||||
<dees-input-list
|
||||
id="interactive-list"
|
||||
.label=${'Product Features'}
|
||||
.placeholder=${'Add a feature...'}
|
||||
.sortable=${true}
|
||||
.confirmDelete=${false}
|
||||
.allowDuplicates=${false}
|
||||
.maxItems=${10}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const preview = document.querySelector('#list-json');
|
||||
if (preview) {
|
||||
const data = {
|
||||
items: e.detail.value,
|
||||
count: e.detail.value.length,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
preview.textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}}
|
||||
></dees-input-list>
|
||||
|
||||
<div class="output-preview" id="list-json">
|
||||
{
|
||||
"items": [],
|
||||
"count": 0,
|
||||
"timestamp": "${new Date().toISOString()}"
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="feature-note">
|
||||
✨ Add, edit, remove, and reorder items to see the JSON output update in real-time
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'8. Advanced Configuration'} .subtitle=${'Combine all features for complex use cases'}>
|
||||
<dees-input-list
|
||||
.label=${'Project Milestones'}
|
||||
.placeholder=${'Add milestone...'}
|
||||
.value=${[
|
||||
'Project Kickoff - Week 1',
|
||||
'Requirements Gathering - Week 2-3',
|
||||
'Design Phase - Week 4-6',
|
||||
'Development Sprint 1 - Week 7-9',
|
||||
'Testing & QA - Week 10-11',
|
||||
'Deployment - Week 12'
|
||||
]}
|
||||
.sortable=${true}
|
||||
.confirmDelete=${true}
|
||||
.allowDuplicates=${false}
|
||||
.minItems=${3}
|
||||
.maxItems=${12}
|
||||
.required=${true}
|
||||
.description=${'Manage project milestones (3-12 items, sortable, no duplicates)'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}>
|
||||
<dees-input-list
|
||||
.label=${'Your Ideas'}
|
||||
.placeholder=${'Share your ideas...'}
|
||||
.value=${[]}
|
||||
.description=${'Start adding items to build your list'}
|
||||
></dees-input-list>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
622
ts_web/elements/00group-input/dees-input-list/dees-input-list.ts
Normal file
622
ts_web/elements/00group-input/dees-input-list/dees-input-list.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import '../../00group-button/dees-button/dees-button.js';
|
||||
import { demoFunc } from './dees-input-list.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-list': DeesInputList;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-list')
|
||||
export class DeesInputList extends DeesInputBase<DeesInputList> {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
accessor value: string[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = 'Add new item...';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxItems: number = 0; // 0 means unlimited
|
||||
|
||||
@property({ type: Number })
|
||||
accessor minItems: number = 0;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor allowDuplicates: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor sortable: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor confirmDelete: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
accessor validationText: string = '';
|
||||
|
||||
@state()
|
||||
accessor inputValue: string = '';
|
||||
|
||||
@state()
|
||||
accessor editingIndex: number = -1;
|
||||
|
||||
@state()
|
||||
accessor editingValue: string = '';
|
||||
|
||||
@state()
|
||||
accessor draggedIndex: number = -1;
|
||||
|
||||
@state()
|
||||
accessor dragOverIndex: number = -1;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.list-container:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.list-container:focus-within {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.list-container.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.list-items {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent animation from affecting scroll bounds */
|
||||
}
|
||||
|
||||
.list-item:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-item:hover:not(.disabled) {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
|
||||
}
|
||||
|
||||
.list-item.dragging {
|
||||
opacity: 0.4;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 10.8%)')};
|
||||
}
|
||||
|
||||
.list-item.drag-over {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 93.1%)', 'hsl(215 20.2% 13.8%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
}
|
||||
|
||||
.drag-handle dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
flex: 1;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.item-edit-input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.action-button.save {
|
||||
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
}
|
||||
|
||||
.action-button.save:hover {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
|
||||
}
|
||||
|
||||
.action-button.cancel {
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')};
|
||||
}
|
||||
|
||||
.action-button.cancel:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')};
|
||||
}
|
||||
|
||||
.action-button.delete {
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 50.6%)')};
|
||||
}
|
||||
|
||||
.action-button.delete:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 50.6% / 0.1)')};
|
||||
}
|
||||
|
||||
.action-button dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.add-item-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.add-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.add-input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.add-input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
}
|
||||
|
||||
.add-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.list-items::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.list-items::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-items::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 24.9%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.list-items::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 34.9%)')};
|
||||
}
|
||||
|
||||
/* Animation for adding/removing items */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* Override any inherited contain/content-visibility that might cause scrolling issues */
|
||||
.list-items, .list-item {
|
||||
content-visibility: visible !important;
|
||||
contain: none !important;
|
||||
contain-intrinsic-size: auto !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
|
||||
|
||||
<div class="list-container ${this.disabled ? 'disabled' : ''}">
|
||||
<div class="list-items">
|
||||
${this.value.length > 0 ? this.value.map((item, index) => html`
|
||||
<div
|
||||
class="list-item ${this.draggedIndex === index ? 'dragging' : ''} ${this.dragOverIndex === index ? 'drag-over' : ''}"
|
||||
draggable="${this.sortable && !this.disabled}"
|
||||
@dragstart=${(e: DragEvent) => this.handleDragStart(e, index)}
|
||||
@dragend=${this.handleDragEnd}
|
||||
@dragover=${(e: DragEvent) => this.handleDragOver(e, index)}
|
||||
@dragleave=${this.handleDragLeave}
|
||||
@drop=${(e: DragEvent) => this.handleDrop(e, index)}
|
||||
>
|
||||
${this.sortable && !this.disabled ? html`
|
||||
<div class="drag-handle">
|
||||
<dees-icon .icon=${'lucide:gripVertical'}></dees-icon>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="item-content">
|
||||
${this.editingIndex === index ? html`
|
||||
<input
|
||||
type="text"
|
||||
class="item-edit-input"
|
||||
.value=${this.editingValue}
|
||||
@input=${(e: InputEvent) => this.editingValue = (e.target as HTMLInputElement).value}
|
||||
@keydown=${(e: KeyboardEvent) => this.handleEditKeyDown(e, index)}
|
||||
@blur=${() => this.saveEdit(index)}
|
||||
/>
|
||||
` : html`
|
||||
<div class="item-text" @dblclick=${() => !this.disabled && this.startEdit(index)}>
|
||||
${item}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
${this.editingIndex === index ? html`
|
||||
<button class="action-button save" @click=${() => this.saveEdit(index)}>
|
||||
<dees-icon .icon=${'lucide:check'}></dees-icon>
|
||||
</button>
|
||||
<button class="action-button cancel" @click=${() => this.cancelEdit()}>
|
||||
<dees-icon .icon=${'lucide:x'}></dees-icon>
|
||||
</button>
|
||||
` : html`
|
||||
${!this.disabled ? html`
|
||||
<button class="action-button" @click=${() => this.startEdit(index)}>
|
||||
<dees-icon .icon=${'lucide:pencil'}></dees-icon>
|
||||
</button>
|
||||
<button class="action-button delete" @click=${() => this.removeItem(index)}>
|
||||
<dees-icon .icon=${'lucide:trash2'}></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="empty-state">
|
||||
No items added yet
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
|
||||
<div class="add-item-container">
|
||||
<input
|
||||
type="text"
|
||||
class="add-input"
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.inputValue}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleAddKeyDown}
|
||||
?disabled=${this.disabled}
|
||||
/>
|
||||
<dees-button
|
||||
class="add-button"
|
||||
@click=${this.addItem}
|
||||
?disabled=${!this.inputValue.trim()}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:plus'}></dees-icon> Add
|
||||
</dees-button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this.validationText ? html`
|
||||
<div class="validation-message">${this.validationText}</div>
|
||||
` : ''}
|
||||
|
||||
${this.description ? html`
|
||||
<div class="description">${this.description}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleInput(e: InputEvent) {
|
||||
this.inputValue = (e.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private handleAddKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && this.inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
this.addItem();
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditKeyDown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.saveEdit(index);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.cancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
private addItem() {
|
||||
const trimmedValue = this.inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
|
||||
if (!this.allowDuplicates && this.value.includes(trimmedValue)) {
|
||||
this.validationText = 'This item already exists in the list';
|
||||
setTimeout(() => this.validationText = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.maxItems && this.value.length >= this.maxItems) {
|
||||
this.validationText = `Maximum ${this.maxItems} items allowed`;
|
||||
setTimeout(() => this.validationText = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = [...this.value, trimmedValue];
|
||||
this.inputValue = '';
|
||||
this.validationText = '';
|
||||
|
||||
// Clear the input
|
||||
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private startEdit(index: number) {
|
||||
this.editingIndex = index;
|
||||
this.editingValue = this.value[index];
|
||||
|
||||
// Focus the input after render
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.shadowRoot?.querySelector('.item-edit-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveEdit(index: number) {
|
||||
const trimmedValue = this.editingValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
this.cancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.allowDuplicates && trimmedValue !== this.value[index] && this.value.includes(trimmedValue)) {
|
||||
this.validationText = 'This item already exists in the list';
|
||||
setTimeout(() => this.validationText = '', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this.value];
|
||||
newValue[index] = trimmedValue;
|
||||
this.value = newValue;
|
||||
|
||||
this.editingIndex = -1;
|
||||
this.editingValue = '';
|
||||
this.validationText = '';
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private cancelEdit() {
|
||||
this.editingIndex = -1;
|
||||
this.editingValue = '';
|
||||
}
|
||||
|
||||
private async removeItem(index: number) {
|
||||
if (this.confirmDelete) {
|
||||
const confirmed = await this.showConfirmDialog(`Delete "${this.value[index]}"?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
this.value = this.value.filter((_, i) => i !== index);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private async showConfirmDialog(message: string): Promise<boolean> {
|
||||
// For now, use native confirm. In production, this should use a proper modal
|
||||
return confirm(message);
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
private handleDragStart(e: DragEvent, index: number) {
|
||||
if (!this.sortable || this.disabled) return;
|
||||
|
||||
this.draggedIndex = index;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer!.setData('text/plain', index.toString());
|
||||
}
|
||||
|
||||
private handleDragEnd() {
|
||||
this.draggedIndex = -1;
|
||||
this.dragOverIndex = -1;
|
||||
}
|
||||
|
||||
private handleDragOver(e: DragEvent, index: number) {
|
||||
if (!this.sortable || this.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
this.dragOverIndex = index;
|
||||
}
|
||||
|
||||
private handleDragLeave() {
|
||||
this.dragOverIndex = -1;
|
||||
}
|
||||
|
||||
private handleDrop(e: DragEvent, dropIndex: number) {
|
||||
if (!this.sortable || this.disabled) return;
|
||||
|
||||
e.preventDefault();
|
||||
const draggedIndex = parseInt(e.dataTransfer!.getData('text/plain'));
|
||||
|
||||
if (draggedIndex !== dropIndex) {
|
||||
const newValue = [...this.value];
|
||||
const [draggedItem] = newValue.splice(draggedIndex, 1);
|
||||
newValue.splice(dropIndex, 0, draggedItem);
|
||||
this.value = newValue;
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
this.draggedIndex = -1;
|
||||
this.dragOverIndex = -1;
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string[]): void {
|
||||
this.value = value || [];
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
if (this.required && (!this.value || this.value.length === 0)) {
|
||||
this.validationText = 'At least one item is required';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.minItems && this.value.length < this.minItems) {
|
||||
this.validationText = `At least ${this.minItems} items required`;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.validationText = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-list/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-list.js';
|
||||
@@ -0,0 +1,163 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
padding: 48px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="section">
|
||||
<div class="section-title">Multi-Option Toggle</div>
|
||||
<div class="section-description">Select from multiple options with a smooth sliding indicator animation.</div>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Display Mode'}
|
||||
.description=${'Choose how content is displayed'}
|
||||
.options=${['List View', 'Grid View', 'Compact']}
|
||||
.selectedOption=${'Grid View'}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<br><br>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'T-Shirt Size'}
|
||||
.description=${'Select your preferred size'}
|
||||
.options=${['XS', 'S', 'M', 'L', 'XL', 'XXL']}
|
||||
.selectedOption=${'M'}
|
||||
></dees-input-multitoggle>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Boolean Toggle</div>
|
||||
<div class="section-description">Simple on/off switches with customizable labels for clearer context.</div>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Notifications'}
|
||||
.description=${'Enable or disable push notifications'}
|
||||
.type=${'boolean'}
|
||||
.selectedOption=${'true'}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<br><br>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Theme Mode'}
|
||||
.description=${'Switch between light and dark theme'}
|
||||
.type=${'boolean'}
|
||||
.booleanTrueName=${'Dark'}
|
||||
.booleanFalseName=${'Light'}
|
||||
.selectedOption=${'Dark'}
|
||||
></dees-input-multitoggle>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Settings Grid</div>
|
||||
<div class="section-description">Configuration options arranged in a responsive grid layout.</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<dees-input-multitoggle
|
||||
.label=${'Auto-Save'}
|
||||
.type=${'boolean'}
|
||||
.booleanTrueName=${'Enabled'}
|
||||
.booleanFalseName=${'Disabled'}
|
||||
.selectedOption=${'Enabled'}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Language'}
|
||||
.options=${['English', 'German', 'French', 'Spanish']}
|
||||
.selectedOption=${'English'}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Quality'}
|
||||
.options=${['Low', 'Medium', 'High', 'Ultra']}
|
||||
.selectedOption=${'High'}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Privacy'}
|
||||
.type=${'boolean'}
|
||||
.booleanTrueName=${'Private'}
|
||||
.booleanFalseName=${'Public'}
|
||||
.selectedOption=${'Private'}
|
||||
></dees-input-multitoggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">States & Form Integration</div>
|
||||
<div class="section-description">Examples of disabled states and integration within forms.</div>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.label=${'Account Type'}
|
||||
.description=${'This setting is locked'}
|
||||
.options=${['Free', 'Pro', 'Enterprise']}
|
||||
.selectedOption=${'Enterprise'}
|
||||
.disabled=${true}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
<br><br>
|
||||
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'Project Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-multitoggle
|
||||
.label=${'Visibility'}
|
||||
.type=${'boolean'}
|
||||
.booleanTrueName=${'Public'}
|
||||
.booleanFalseName=${'Private'}
|
||||
.selectedOption=${'Private'}
|
||||
></dees-input-multitoggle>
|
||||
<dees-input-multitoggle
|
||||
.label=${'License'}
|
||||
.options=${['MIT', 'Apache 2.0', 'GPL v3', 'Proprietary']}
|
||||
.selectedOption=${'MIT'}
|
||||
></dees-input-multitoggle>
|
||||
</dees-form>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
|
||||
import * as colors from '../../00colors.js'
|
||||
|
||||
import { demoFunc } from './dees-input-multitoggle.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-multitoggle': DeesInputMultitoggle;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-multitoggle')
|
||||
export class DeesInputMultitoggle extends DeesInputBase<DeesInputMultitoggle> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
|
||||
@property()
|
||||
accessor type: 'boolean' | 'multi' | 'single' = 'multi';
|
||||
|
||||
@property()
|
||||
accessor booleanTrueName: string = 'true';
|
||||
|
||||
@property()
|
||||
accessor booleanFalseName: string = 'false';
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
accessor options: string[] = [];
|
||||
|
||||
@property()
|
||||
accessor selectedOption: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor boolValue: boolean = false;
|
||||
|
||||
// Add value property for form compatibility
|
||||
public get value(): string | boolean {
|
||||
if (this.type === 'boolean') {
|
||||
return this.selectedOption === this.booleanTrueName;
|
||||
}
|
||||
return this.selectedOption;
|
||||
}
|
||||
|
||||
public set value(val: string | boolean) {
|
||||
if (this.type === 'boolean' && typeof val === 'boolean') {
|
||||
this.selectedOption = val ? this.booleanTrueName : this.booleanFalseName;
|
||||
} else {
|
||||
this.selectedOption = val as string;
|
||||
}
|
||||
this.requestUpdate();
|
||||
// Defer indicator update to next frame if component not yet updated
|
||||
if (this.hasUpdated) {
|
||||
requestAnimationFrame(() => {
|
||||
this.setIndicator();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.selections {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.option {
|
||||
position: relative;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
line-height: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
color: ${cssManager.bdTheme('#18181b', '#e4e4e7')};
|
||||
}
|
||||
|
||||
.option.selected {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
height: calc(100% - 8px);
|
||||
top: 4px;
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(59, 130, 246, 0.15)')};
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.indicator.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
:host([disabled]) .selections {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:host([disabled]) .option {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host([disabled]) .indicator {
|
||||
background: ${cssManager.bdTheme('rgba(113, 113, 122, 0.15)', 'rgba(113, 113, 122, 0.15)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description}></dees-label>
|
||||
<div class="mainbox">
|
||||
<div class="selections">
|
||||
<div class="indicator"></div>
|
||||
${this.options.map(
|
||||
(option) =>
|
||||
html`<div class="option ${option === this.selectedOption ? 'selected': ''}" @click=${() => this.handleSelection(option)}>
|
||||
${option}
|
||||
</div> `
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Initialize boolean options early
|
||||
if (this.type === 'boolean' && this.options.length === 0) {
|
||||
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
|
||||
// Set default selection for boolean if not set
|
||||
if (!this.selectedOption) {
|
||||
this.selectedOption = this.booleanFalseName || 'false';
|
||||
}
|
||||
}
|
||||
// Set default selection to first option if not set
|
||||
if (!this.selectedOption && this.options.length > 0) {
|
||||
this.selectedOption = this.options[0];
|
||||
}
|
||||
}
|
||||
|
||||
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
// Update boolean options if they changed
|
||||
if (this.type === 'boolean') {
|
||||
this.options = [this.booleanTrueName || 'true', this.booleanFalseName || 'false'];
|
||||
}
|
||||
// Wait for the next frame to ensure DOM is fully rendered
|
||||
await this.updateComplete;
|
||||
|
||||
// Wait for fonts to load
|
||||
if (document.fonts) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
// Wait one more frame after fonts are loaded
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Now set the indicator
|
||||
this.setIndicator();
|
||||
}
|
||||
|
||||
public async handleSelection(optionArg: string) {
|
||||
if (this.disabled) return;
|
||||
this.selectedOption = optionArg;
|
||||
this.requestUpdate();
|
||||
this.changeSubject.next(this);
|
||||
await this.updateComplete;
|
||||
this.setIndicator();
|
||||
}
|
||||
|
||||
private indicatorInitialized = false;
|
||||
|
||||
public async setIndicator() {
|
||||
const indicator: HTMLDivElement = this.shadowRoot.querySelector('.indicator');
|
||||
const selectedIndex = this.options.indexOf(this.selectedOption);
|
||||
|
||||
// If no valid selection, hide indicator
|
||||
if (selectedIndex === -1 || !indicator) {
|
||||
if (indicator) {
|
||||
indicator.style.opacity = '0';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const option: HTMLDivElement = this.shadowRoot.querySelector(
|
||||
`.option:nth-child(${selectedIndex + 2})`
|
||||
);
|
||||
|
||||
if (indicator && option) {
|
||||
// Only disable transition for the very first positioning
|
||||
if (!this.indicatorInitialized) {
|
||||
indicator.classList.add('no-transition');
|
||||
this.indicatorInitialized = true;
|
||||
|
||||
// Remove the no-transition class after a brief delay
|
||||
setTimeout(() => {
|
||||
indicator.classList.remove('no-transition');
|
||||
}, 50);
|
||||
}
|
||||
|
||||
indicator.style.width = `${option.clientWidth}px`;
|
||||
indicator.style.left = `${option.offsetLeft}px`;
|
||||
indicator.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): string | boolean {
|
||||
if (this.type === 'boolean') {
|
||||
return this.selectedOption === this.booleanTrueName;
|
||||
}
|
||||
return this.selectedOption;
|
||||
}
|
||||
|
||||
public setValue(value: string | boolean): void {
|
||||
if (this.type === 'boolean' && typeof value === 'boolean') {
|
||||
this.selectedOption = value ? (this.booleanTrueName || 'true') : (this.booleanFalseName || 'false');
|
||||
} else {
|
||||
this.selectedOption = value as string;
|
||||
}
|
||||
this.requestUpdate();
|
||||
if (this.hasUpdated) {
|
||||
requestAnimationFrame(() => {
|
||||
this.setIndicator();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-multitoggle.js';
|
||||
@@ -0,0 +1,80 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Basic Phone Input'} .subtitle=${'Automatic formatting for phone numbers'}>
|
||||
<dees-input-phone
|
||||
.label=${'Phone Number'}
|
||||
.description=${'Enter your phone number with country code'}
|
||||
.value=${'5551234567'}
|
||||
></dees-input-phone>
|
||||
|
||||
<dees-input-phone
|
||||
.label=${'Contact Phone'}
|
||||
.description=${'Required for account verification'}
|
||||
.required=${true}
|
||||
.placeholder=${'+1 (555) 000-0000'}
|
||||
></dees-input-phone>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Phone inputs arranged horizontally'}>
|
||||
<div class="horizontal-group">
|
||||
<dees-input-phone
|
||||
.label=${'Mobile'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${'4155551234'}
|
||||
></dees-input-phone>
|
||||
|
||||
<dees-input-phone
|
||||
.label=${'Office'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.placeholder=${'+1 (800) 555-0000'}
|
||||
></dees-input-phone>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'International Numbers'} .subtitle=${'Supports formatting for numbers with country codes'}>
|
||||
<dees-input-phone
|
||||
.label=${'International Contact'}
|
||||
.description=${'Automatically formats international numbers'}
|
||||
.value=${'441234567890'}
|
||||
></dees-input-phone>
|
||||
|
||||
<dees-input-phone
|
||||
.label=${'Emergency Contact'}
|
||||
.value=${'911'}
|
||||
.disabled=${true}
|
||||
></dees-input-phone>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Form Integration'} .subtitle=${'Phone input as part of a contact form'}>
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'Full Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-phone .label=${'Phone Number'} .required=${true}></dees-input-phone>
|
||||
<dees-input-text .label=${'Email'} .inputType=${'email'}></dees-input-text>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-phone.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-phone': DeesInputPhone;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-phone')
|
||||
export class DeesInputPhone extends DeesInputBase<DeesInputPhone> {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@state()
|
||||
accessor formattedPhone: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor value: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = '+1 (555) 123-4567';
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
/* Phone input specific styles can go here */
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description}></dees-label>
|
||||
<dees-input-text
|
||||
.value=${this.formattedPhone}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.placeholder=${this.placeholder}
|
||||
@input=${(event: InputEvent) => this.handlePhoneInput(event)}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.firstUpdated(_changedProperties);
|
||||
// Initialize formatted phone from value
|
||||
if (this.value) {
|
||||
this.formattedPhone = this.formatPhoneNumber(this.value);
|
||||
}
|
||||
|
||||
// Subscribe to the inner input's changes
|
||||
const innerInput = this.shadowRoot.querySelector('dees-input-text') as any;
|
||||
if (innerInput && innerInput.changeSubject) {
|
||||
innerInput.changeSubject.subscribe(() => {
|
||||
this.changeSubject.next(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handlePhoneInput(event: InputEvent) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const cleanedValue = this.cleanPhoneNumber(input.value);
|
||||
const formatted = this.formatPhoneNumber(cleanedValue);
|
||||
|
||||
// Update the input with formatted value
|
||||
if (input.value !== formatted) {
|
||||
const cursorPosition = input.selectionStart || 0;
|
||||
input.value = formatted;
|
||||
|
||||
// Try to maintain cursor position intelligently
|
||||
const newCursorPos = this.calculateCursorPosition(cleanedValue, formatted, cursorPosition);
|
||||
input.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
|
||||
this.formattedPhone = formatted;
|
||||
this.value = cleanedValue;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
private cleanPhoneNumber(value: string): string {
|
||||
// Remove all non-numeric characters
|
||||
return value.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
private formatPhoneNumber(value: string): string {
|
||||
// Basic US phone number formatting
|
||||
// This can be enhanced to support international formats
|
||||
const cleaned = this.cleanPhoneNumber(value);
|
||||
|
||||
if (cleaned.length === 0) return '';
|
||||
if (cleaned.length <= 3) return cleaned;
|
||||
if (cleaned.length <= 6) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
|
||||
if (cleaned.length <= 10) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
|
||||
// For numbers longer than 10 digits, format as international
|
||||
return `+${cleaned.slice(0, cleaned.length - 10)} (${cleaned.slice(-10, -7)}) ${cleaned.slice(-7, -4)}-${cleaned.slice(-4)}`;
|
||||
}
|
||||
|
||||
private calculateCursorPosition(cleaned: string, formatted: string, oldPos: number): number {
|
||||
// Simple cursor position calculation
|
||||
// Count how many formatting characters are before the cursor
|
||||
let formattingChars = 0;
|
||||
for (let i = 0; i < oldPos && i < formatted.length; i++) {
|
||||
if (!/\d/.test(formatted[i])) {
|
||||
formattingChars++;
|
||||
}
|
||||
}
|
||||
return Math.min(oldPos + formattingChars, formatted.length);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
this.formattedPhone = this.formatPhoneNumber(value);
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-phone/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-phone/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-phone.js';
|
||||
@@ -0,0 +1,208 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '../../dees-shopping-productcard/dees-shopping-productcard.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shopping-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cart-summary {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(215 20.2% 21.8%)')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cart-summary-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
||||
}
|
||||
|
||||
.cart-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 2px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Basic Quantity Selector'} .subtitle=${'Simple quantity input with increment/decrement buttons'}>
|
||||
<dees-input-quantityselector
|
||||
.label=${'Quantity'}
|
||||
.description=${'Select the desired quantity'}
|
||||
.value=${1}
|
||||
></dees-input-quantityselector>
|
||||
|
||||
<dees-input-quantityselector
|
||||
.label=${'Items in Cart'}
|
||||
.description=${'Adjust the quantity of items'}
|
||||
.value=${3}
|
||||
></dees-input-quantityselector>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Shopping Cart'} .subtitle=${'Modern e-commerce product cards with interactive quantity selectors'} .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const updateCartSummary = () => {
|
||||
const card1 = elementArg.querySelector('#headphones-qty') as any;
|
||||
const card2 = elementArg.querySelector('#mouse-qty') as any;
|
||||
const card3 = elementArg.querySelector('#keyboard-qty') as any;
|
||||
|
||||
const qty1 = card1?.quantity || 0;
|
||||
const qty2 = card2?.quantity || 0;
|
||||
const qty3 = card3?.quantity || 0;
|
||||
|
||||
const price1 = 349.99 * qty1;
|
||||
const price2 = 99.99 * qty2;
|
||||
const price3 = 79.99 * qty3;
|
||||
const total = price1 + price2 + price3;
|
||||
|
||||
const summary = elementArg.querySelector('#cart-summary-content');
|
||||
if (summary) {
|
||||
summary.innerHTML = `
|
||||
${qty1 > 0 ? `<div class="cart-item">
|
||||
<span>Sony WH-1000XM5 (${qty1})</span>
|
||||
<span>$${price1.toFixed(2)}</span>
|
||||
</div>` : ''}
|
||||
${qty2 > 0 ? `<div class="cart-item">
|
||||
<span>Logitech MX Master 3S (${qty2})</span>
|
||||
<span>$${price2.toFixed(2)}</span>
|
||||
</div>` : ''}
|
||||
${qty3 > 0 ? `<div class="cart-item">
|
||||
<span>Keychron K2 (${qty3})</span>
|
||||
<span>$${price3.toFixed(2)}</span>
|
||||
</div>` : ''}
|
||||
${total === 0 ? '<div class="cart-item" style="text-align: center; color: #999;">Your cart is empty</div>' : ''}
|
||||
<div class="cart-total">
|
||||
<span>Total</span>
|
||||
<span>$${total.toFixed(2)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial update
|
||||
setTimeout(updateCartSummary, 100);
|
||||
|
||||
// Set up listeners
|
||||
elementArg.querySelectorAll('dees-shopping-productcard').forEach(card => {
|
||||
card.addEventListener('quantityChange', updateCartSummary);
|
||||
});
|
||||
}}>
|
||||
<div class="shopping-grid">
|
||||
<dees-shopping-productcard
|
||||
id="headphones-qty"
|
||||
.productData=${{
|
||||
name: 'Sony WH-1000XM5 Wireless Headphones',
|
||||
category: 'Audio',
|
||||
description: 'Industry-leading noise canceling with Auto NC Optimizer',
|
||||
price: 349.99,
|
||||
originalPrice: 399.99,
|
||||
iconName: 'lucide:headphones'
|
||||
}}
|
||||
.quantity=${1}
|
||||
></dees-shopping-productcard>
|
||||
|
||||
<dees-shopping-productcard
|
||||
id="mouse-qty"
|
||||
.productData=${{
|
||||
name: 'Logitech MX Master 3S',
|
||||
category: 'Accessories',
|
||||
description: 'Performance wireless mouse with ultra-fast scrolling',
|
||||
price: 99.99,
|
||||
iconName: 'lucide:mouse-pointer'
|
||||
}}
|
||||
.quantity=${2}
|
||||
></dees-shopping-productcard>
|
||||
|
||||
<dees-shopping-productcard
|
||||
id="keyboard-qty"
|
||||
.productData=${{
|
||||
name: 'Keychron K2 Wireless Mechanical Keyboard',
|
||||
category: 'Keyboards',
|
||||
description: 'Compact 75% layout with hot-swappable switches',
|
||||
price: 79.99,
|
||||
originalPrice: 94.99,
|
||||
iconName: 'lucide:keyboard'
|
||||
}}
|
||||
.quantity=${1}
|
||||
></dees-shopping-productcard>
|
||||
</div>
|
||||
|
||||
<div class="cart-summary">
|
||||
<h3 class="cart-summary-title">Order Summary</h3>
|
||||
<div id="cart-summary-content">
|
||||
<!-- Content will be dynamically updated -->
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different states for validation and restrictions'}>
|
||||
<dees-input-quantityselector
|
||||
.label=${'Number of Licenses'}
|
||||
.description=${'Select how many licenses you need'}
|
||||
.required=${true}
|
||||
.value=${1}
|
||||
></dees-input-quantityselector>
|
||||
|
||||
<dees-input-quantityselector
|
||||
.label=${'Fixed Quantity'}
|
||||
.description=${'This quantity cannot be changed'}
|
||||
.disabled=${true}
|
||||
.value=${5}
|
||||
></dees-input-quantityselector>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Order Form'} .subtitle=${'Complete order form with quantity selection'}>
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'Customer Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.label=${'Product'}
|
||||
.options=${['Basic Plan', 'Pro Plan', 'Enterprise Plan']}
|
||||
.required=${true}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-quantityselector
|
||||
.label=${'Quantity'}
|
||||
.description=${'Number of licenses'}
|
||||
.value=${1}
|
||||
></dees-input-quantityselector>
|
||||
<dees-input-text
|
||||
.label=${'Special Instructions'}
|
||||
.inputType=${'textarea'}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { customElement, property, html, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-quantityselector.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-quantityselector': DeesInputQuantitySelector;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-quantityselector')
|
||||
export class DeesInputQuantitySelector extends DeesInputBase<DeesInputQuantitySelector> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
|
||||
@property({
|
||||
type: Number
|
||||
})
|
||||
accessor value: number = 1;
|
||||
|
||||
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
width: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quantity-container {
|
||||
transition: all 0.15s ease;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
min-width: 120px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quantity-container.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quantity-container:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.quantity-container:focus-within {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.selector {
|
||||
flex: 0 0 40px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.selector:active {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')};
|
||||
}
|
||||
|
||||
.selector.minus {
|
||||
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.selector.plus {
|
||||
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.quantity {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
|
||||
/* Keyboard navigation focus styles */
|
||||
.selector:focus {
|
||||
outline: none;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Min value state */
|
||||
.quantity-container[data-min="true"] .selector.minus {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quantity-container[data-min="true"] .selector.minus:hover {
|
||||
background: transparent;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
}
|
||||
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>` : ''}
|
||||
<div
|
||||
class="quantity-container ${this.disabled ? 'disabled' : ''}"
|
||||
data-min="${this.value <= 0}"
|
||||
>
|
||||
<div
|
||||
class="selector minus"
|
||||
@click="${() => {this.decrease();}}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.decrease();
|
||||
}
|
||||
}}"
|
||||
role="button"
|
||||
aria-label="Decrease quantity"
|
||||
>−</div>
|
||||
<div class="quantity" aria-live="polite" aria-atomic="true">${this.value}</div>
|
||||
<div
|
||||
class="selector plus"
|
||||
@click="${() => {this.increase();}}"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
@keydown="${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.increase();
|
||||
}
|
||||
}}"
|
||||
role="button"
|
||||
aria-label="Increase quantity"
|
||||
>+</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public increase() {
|
||||
if (!this.disabled) {
|
||||
this.value++;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public decrease() {
|
||||
if (!this.disabled && this.value > 0) {
|
||||
this.value--;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): number {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: number): void {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-quantityselector.js';
|
||||
@@ -0,0 +1,200 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.result-display {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 105, 242, 0.1);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic Radio Groups'} .subtitle=${'Simple string options for common use cases'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-radiogroup
|
||||
.label=${'Subscription Plan'}
|
||||
.options=${['Basic - $9/month', 'Pro - $29/month', 'Enterprise - $99/month']}
|
||||
.selectedOption=${'Pro - $29/month'}
|
||||
.description=${'Choose your subscription tier'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Priority Level'}
|
||||
.options=${['High', 'Medium', 'Low']}
|
||||
.selectedOption=${'Medium'}
|
||||
.required=${true}
|
||||
></dees-input-radiogroup>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. Horizontal Layout'} .subtitle=${'Radio groups with horizontal arrangement'}>
|
||||
<dees-input-radiogroup
|
||||
.label=${'Do you agree with the terms?'}
|
||||
.options=${['Yes', 'No', 'Maybe']}
|
||||
.direction=${'horizontal'}
|
||||
.selectedOption=${'Yes'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Experience Level'}
|
||||
.options=${['Beginner', 'Intermediate', 'Expert']}
|
||||
.direction=${'horizontal'}
|
||||
.selectedOption=${'Intermediate'}
|
||||
.description=${'Select your experience level with web development'}
|
||||
></dees-input-radiogroup>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Advanced Options'} .subtitle=${'Using object format with keys and payloads'}>
|
||||
<dees-input-radiogroup
|
||||
id="advanced-radio"
|
||||
.label=${'Select Region'}
|
||||
.options=${[
|
||||
{ option: 'United States (US East)', key: 'us-east', payload: { region: 'us-east-1', latency: 20 } },
|
||||
{ option: 'Europe (Frankfurt)', key: 'eu-central', payload: { region: 'eu-central-1', latency: 50 } },
|
||||
{ option: 'Asia Pacific (Singapore)', key: 'ap-southeast', payload: { region: 'ap-southeast-1', latency: 120 } }
|
||||
]}
|
||||
.selectedOption=${'eu-central'}
|
||||
.description=${'Choose the closest region for optimal performance'}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const display = document.querySelector('#region-result');
|
||||
if (display) {
|
||||
display.textContent = 'Selected: ' + JSON.stringify(e.detail.value, null, 2);
|
||||
}
|
||||
}}
|
||||
></dees-input-radiogroup>
|
||||
<div id="region-result" class="result-display">Selected: { "region": "eu-central-1", "latency": 50 }</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. Survey Example'} .subtitle=${'Multiple radio groups for surveys and forms'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-radiogroup
|
||||
.label=${'How satisfied are you?'}
|
||||
.options=${['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied', 'Very Dissatisfied']}
|
||||
.selectedOption=${'Satisfied'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Would you recommend us?'}
|
||||
.options=${['Definitely', 'Probably', 'Not Sure', 'Probably Not', 'Definitely Not']}
|
||||
.selectedOption=${'Probably'}
|
||||
></dees-input-radiogroup>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. States & Validation'} .subtitle=${'Different states and validation examples'}>
|
||||
<div class="demo-grid">
|
||||
<dees-input-radiogroup
|
||||
.label=${'Required Selection'}
|
||||
.options=${['Option A', 'Option B', 'Option C']}
|
||||
.required=${true}
|
||||
.description=${'This field is required'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Disabled State'}
|
||||
.options=${['Disabled Option 1', 'Disabled Option 2', 'Disabled Option 3']}
|
||||
.selectedOption=${'Disabled Option 2'}
|
||||
.disabled=${true}
|
||||
></dees-input-radiogroup>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'6. Settings Example'} .subtitle=${'Common patterns in application settings'}>
|
||||
<dees-input-radiogroup
|
||||
.label=${'Theme Preference'}
|
||||
.options=${[
|
||||
{ option: 'Light Theme', key: 'light', payload: 'light' },
|
||||
{ option: 'Dark Theme', key: 'dark', payload: 'dark' },
|
||||
{ option: 'System Default', key: 'system', payload: 'auto' }
|
||||
]}
|
||||
.selectedOption=${'dark'}
|
||||
.description=${'Choose how the application should appear'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Notification Frequency'}
|
||||
.options=${['All Notifications', 'Important Only', 'None']}
|
||||
.selectedOption=${'Important Only'}
|
||||
.description=${'Control how often you receive notifications'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Language'}
|
||||
.options=${['English', 'German', 'French', 'Spanish', 'Japanese']}
|
||||
.selectedOption=${'English'}
|
||||
.direction=${'horizontal'}
|
||||
></dees-input-radiogroup>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'7. Form Integration'} .subtitle=${'Works seamlessly with dees-form'}>
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.label=${'Product Name'}
|
||||
.required=${true}
|
||||
.key=${'productName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Product Category'}
|
||||
.options=${['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports']}
|
||||
.required=${true}
|
||||
.key=${'category'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Condition'}
|
||||
.options=${['New', 'Like New', 'Good', 'Fair', 'Poor']}
|
||||
.direction=${'horizontal'}
|
||||
.key=${'condition'}
|
||||
.selectedOption=${'New'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-input-radiogroup
|
||||
.label=${'Shipping Speed'}
|
||||
.options=${[
|
||||
{ option: 'Standard (5-7 days)', key: 'standard', payload: { days: 7, price: 0 } },
|
||||
{ option: 'Express (2-3 days)', key: 'express', payload: { days: 3, price: 10 } },
|
||||
{ option: 'Overnight', key: 'overnight', payload: { days: 1, price: 25 } }
|
||||
]}
|
||||
.selectedOption=${'standard'}
|
||||
.key=${'shipping'}
|
||||
></dees-input-radiogroup>
|
||||
|
||||
<dees-form-submit .text=${'Submit Product'}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-radiogroup.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-radiogroup': DeesInputRadiogroup;
|
||||
}
|
||||
}
|
||||
|
||||
type RadioOption = string | { option: string; key: string; payload?: any };
|
||||
|
||||
@customElement('dees-input-radiogroup')
|
||||
export class DeesInputRadiogroup extends DeesInputBase<string | object> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
|
||||
@property({ type: Array })
|
||||
accessor options: RadioOption[] = [];
|
||||
|
||||
@property()
|
||||
accessor selectedOption: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor direction: 'vertical' | 'horizontal' = 'vertical';
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
accessor validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
||||
|
||||
// Form compatibility
|
||||
public get value() {
|
||||
const option = this.getOptionByKey(this.selectedOption);
|
||||
if (typeof option === 'object' && option.payload !== undefined) {
|
||||
return option.payload;
|
||||
}
|
||||
return this.selectedOption;
|
||||
}
|
||||
|
||||
public set value(val: string | any) {
|
||||
if (typeof val === 'string') {
|
||||
this.selectedOption = val;
|
||||
} else {
|
||||
// Try to find option by payload
|
||||
const option = this.options.find(opt =>
|
||||
typeof opt === 'object' && opt.payload === val
|
||||
);
|
||||
if (option && typeof option === 'object') {
|
||||
this.selectedOption = option.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.maincontainer.horizontal {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.maincontainer.horizontal .radio-option {
|
||||
padding: 6px 20px 6px 0;
|
||||
}
|
||||
|
||||
.radio-option:hover .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 11.8%)')};
|
||||
}
|
||||
|
||||
.radio-option:hover .radio-label {
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
|
||||
.radio-circle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radio-option.selected .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
}
|
||||
|
||||
.radio-option.selected .radio-circle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
||||
transform: scale(0);
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.radio-option.selected .radio-circle::after {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.radio-circle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 3.9%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
||||
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.radio-option.selected .radio-label {
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
}
|
||||
|
||||
:host([disabled]) .radio-option {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:host([disabled]) .radio-option:hover .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 30% 6.8%)')};
|
||||
}
|
||||
|
||||
:host([disabled]) .radio-option:hover .radio-label {
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 8.8%)', 'hsl(210 40% 98%)')};
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.003em;
|
||||
}
|
||||
|
||||
/* Validation styles */
|
||||
:host([validationState="invalid"]) .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
}
|
||||
|
||||
:host([validationState="invalid"]) .radio-option.selected .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
}
|
||||
|
||||
:host([validationState="valid"]) .radio-option.selected .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
|
||||
background: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
|
||||
}
|
||||
|
||||
:host([validationState="warn"]) .radio-option.selected .radio-circle {
|
||||
border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
|
||||
background: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
|
||||
}
|
||||
|
||||
/* Override base grid layout for radiogroup to prevent large gaps */
|
||||
:host([label-position="left"]) .input-wrapper {
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
:host([label-position="right"]) .input-wrapper {
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`<div class="label-text">${this.label}</div>` : ''}
|
||||
<div class="maincontainer ${this.direction}">
|
||||
${this.options.map((option) => {
|
||||
const optionKey = this.getOptionKey(option);
|
||||
const optionLabel = this.getOptionLabel(option);
|
||||
const isSelected = this.selectedOption === optionKey;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="radio-option ${isSelected ? 'selected' : ''}"
|
||||
@click="${() => this.selectOption(optionKey)}"
|
||||
@keydown="${(e: KeyboardEvent) => this.handleKeydown(e, optionKey)}"
|
||||
>
|
||||
<div
|
||||
class="radio-circle"
|
||||
tabindex="${this.disabled ? '-1' : '0'}"
|
||||
role="radio"
|
||||
aria-checked="${isSelected}"
|
||||
aria-label="${optionLabel}"
|
||||
></div>
|
||||
<div class="radio-label">${optionLabel}</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${this.description ? html`<div class="description-text">${this.description}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getOptionKey(option: RadioOption): string {
|
||||
if (typeof option === 'string') {
|
||||
return option;
|
||||
}
|
||||
return option.key;
|
||||
}
|
||||
|
||||
private getOptionLabel(option: RadioOption): string {
|
||||
if (typeof option === 'string') {
|
||||
return option;
|
||||
}
|
||||
return option.option;
|
||||
}
|
||||
|
||||
private getOptionByKey(key: string): RadioOption | undefined {
|
||||
return this.options.find(opt => this.getOptionKey(opt) === key);
|
||||
}
|
||||
|
||||
private selectOption(key: string): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldValue = this.selectedOption;
|
||||
this.selectedOption = key;
|
||||
|
||||
if (oldValue !== key) {
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
this.dispatchEvent(new CustomEvent('input', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): string | any {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(val: string | any): void {
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
if (this.required && !this.selectedOption) {
|
||||
this.validationState = 'invalid';
|
||||
return false;
|
||||
}
|
||||
|
||||
this.validationState = 'valid';
|
||||
return true;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Auto-select first option if none selected and not required
|
||||
if (!this.selectedOption && this.options.length > 0 && !this.required) {
|
||||
const firstOption = this.options[0];
|
||||
this.selectedOption = this.getOptionKey(firstOption);
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(event: KeyboardEvent, optionKey: string) {
|
||||
if (this.disabled) return;
|
||||
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.selectOption(optionKey);
|
||||
} else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.focusNextOption();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.focusPreviousOption();
|
||||
}
|
||||
}
|
||||
|
||||
private focusNextOption() {
|
||||
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
|
||||
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
|
||||
const nextIndex = (currentIndex + 1) % radioCircles.length;
|
||||
(radioCircles[nextIndex] as HTMLElement).focus();
|
||||
}
|
||||
|
||||
private focusPreviousOption() {
|
||||
const radioCircles = Array.from(this.shadowRoot.querySelectorAll('.radio-circle'));
|
||||
const currentIndex = radioCircles.findIndex(el => el === this.shadowRoot.activeElement);
|
||||
const prevIndex = currentIndex <= 0 ? radioCircles.length - 1 : currentIndex - 1;
|
||||
(radioCircles[prevIndex] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-radiogroup.js';
|
||||
383
ts_web/elements/00group-input/dees-input-richtext/component.ts
Normal file
383
ts_web/elements/00group-input/dees-input-richtext/component.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { richtextStyles } from './styles.js';
|
||||
import { renderRichtext } from './template.js';
|
||||
import type { IToolbarButton } from './types.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
state,
|
||||
query,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-richtext': DeesInputRichtext;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@customElement('dees-input-richtext')
|
||||
export class DeesInputRichtext extends DeesInputBase<string> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: String,
|
||||
reflect: true,
|
||||
})
|
||||
accessor value: string = '';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
accessor placeholder: string = '';
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
accessor showWordCount: boolean = true;
|
||||
|
||||
@property({
|
||||
type: Number,
|
||||
})
|
||||
accessor minHeight: number = 200;
|
||||
|
||||
@state()
|
||||
accessor showLinkInput: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor wordCount: number = 0;
|
||||
|
||||
private editorElement: HTMLElement;
|
||||
private linkInputElement: HTMLInputElement;
|
||||
|
||||
public editor: Editor;
|
||||
|
||||
public static styles = richtextStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderRichtext(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public renderToolbar(): TemplateResult {
|
||||
const buttons: IToolbarButton[] = this.getToolbarButtons();
|
||||
|
||||
return html`
|
||||
${buttons.map((button) => {
|
||||
if (button.isDivider) {
|
||||
return html`<div class="toolbar-divider"></div>`;
|
||||
}
|
||||
return html`
|
||||
<button
|
||||
class="toolbar-button ${button.isActive?.() ? 'active' : ''}"
|
||||
@click=${button.action}
|
||||
title=${button.title}
|
||||
?disabled=${this.disabled || !this.editor}
|
||||
>
|
||||
<dees-icon .icon=${button.icon}></dees-icon>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
private getToolbarButtons(): IToolbarButton[] {
|
||||
if (!this.editor) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'bold',
|
||||
icon: 'lucide:bold',
|
||||
title: 'Bold (Ctrl+B)',
|
||||
action: () => this.editor.chain().focus().toggleBold().run(),
|
||||
isActive: () => this.editor.isActive('bold'),
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
icon: 'lucide:italic',
|
||||
title: 'Italic (Ctrl+I)',
|
||||
action: () => this.editor.chain().focus().toggleItalic().run(),
|
||||
isActive: () => this.editor.isActive('italic'),
|
||||
},
|
||||
{
|
||||
name: 'underline',
|
||||
icon: 'lucide:underline',
|
||||
title: 'Underline (Ctrl+U)',
|
||||
action: () => this.editor.chain().focus().toggleUnderline().run(),
|
||||
isActive: () => this.editor.isActive('underline'),
|
||||
},
|
||||
{
|
||||
name: 'strike',
|
||||
icon: 'lucide:strikethrough',
|
||||
title: 'Strikethrough',
|
||||
action: () => this.editor.chain().focus().toggleStrike().run(),
|
||||
isActive: () => this.editor.isActive('strike'),
|
||||
},
|
||||
{ name: 'divider1', title: '', isDivider: true },
|
||||
{
|
||||
name: 'h1',
|
||||
icon: 'lucide:heading1',
|
||||
title: 'Heading 1',
|
||||
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
isActive: () => this.editor.isActive('heading', { level: 1 }),
|
||||
},
|
||||
{
|
||||
name: 'h2',
|
||||
icon: 'lucide:heading2',
|
||||
title: 'Heading 2',
|
||||
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
isActive: () => this.editor.isActive('heading', { level: 2 }),
|
||||
},
|
||||
{
|
||||
name: 'h3',
|
||||
icon: 'lucide:heading3',
|
||||
title: 'Heading 3',
|
||||
action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
isActive: () => this.editor.isActive('heading', { level: 3 }),
|
||||
},
|
||||
{ name: 'divider2', title: '', isDivider: true },
|
||||
{
|
||||
name: 'bulletList',
|
||||
icon: 'lucide:list',
|
||||
title: 'Bullet List',
|
||||
action: () => this.editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => this.editor.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
name: 'orderedList',
|
||||
icon: 'lucide:listOrdered',
|
||||
title: 'Numbered List',
|
||||
action: () => this.editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => this.editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'blockquote',
|
||||
icon: 'lucide:quote',
|
||||
title: 'Quote',
|
||||
action: () => this.editor.chain().focus().toggleBlockquote().run(),
|
||||
isActive: () => this.editor.isActive('blockquote'),
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
icon: 'lucide:code',
|
||||
title: 'Code',
|
||||
action: () => this.editor.chain().focus().toggleCode().run(),
|
||||
isActive: () => this.editor.isActive('code'),
|
||||
},
|
||||
{
|
||||
name: 'codeBlock',
|
||||
icon: 'lucide:fileCode',
|
||||
title: 'Code Block',
|
||||
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => this.editor.isActive('codeBlock'),
|
||||
},
|
||||
{ name: 'divider3', title: '', isDivider: true },
|
||||
{
|
||||
name: 'link',
|
||||
icon: 'lucide:link',
|
||||
title: 'Add Link',
|
||||
action: () => this.toggleLink(),
|
||||
isActive: () => this.editor.isActive('link'),
|
||||
},
|
||||
{
|
||||
name: 'alignLeft',
|
||||
icon: 'lucide:alignLeft',
|
||||
title: 'Align Left',
|
||||
action: () => this.editor.chain().focus().setTextAlign('left').run(),
|
||||
isActive: () => this.editor.isActive({ textAlign: 'left' }),
|
||||
},
|
||||
{
|
||||
name: 'alignCenter',
|
||||
icon: 'lucide:alignCenter',
|
||||
title: 'Align Center',
|
||||
action: () => this.editor.chain().focus().setTextAlign('center').run(),
|
||||
isActive: () => this.editor.isActive({ textAlign: 'center' }),
|
||||
},
|
||||
{
|
||||
name: 'alignRight',
|
||||
icon: 'lucide:alignRight',
|
||||
title: 'Align Right',
|
||||
action: () => this.editor.chain().focus().setTextAlign('right').run(),
|
||||
isActive: () => this.editor.isActive({ textAlign: 'right' }),
|
||||
},
|
||||
{ name: 'divider4', title: '', isDivider: true },
|
||||
{
|
||||
name: 'undo',
|
||||
icon: 'lucide:undo',
|
||||
title: 'Undo (Ctrl+Z)',
|
||||
action: () => this.editor.chain().focus().undo().run(),
|
||||
},
|
||||
{
|
||||
name: 'redo',
|
||||
icon: 'lucide:redo',
|
||||
title: 'Redo (Ctrl+Y)',
|
||||
action: () => this.editor.chain().focus().redo().run(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
await this.updateComplete;
|
||||
this.editorElement = this.shadowRoot.querySelector('.editor-content');
|
||||
this.linkInputElement = this.shadowRoot.querySelector('.link-input input');
|
||||
this.initializeEditor();
|
||||
}
|
||||
|
||||
private initializeEditor(): void {
|
||||
if (this.disabled) return;
|
||||
|
||||
this.editor = new Editor({
|
||||
element: this.editorElement,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'editor-link',
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
],
|
||||
content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''),
|
||||
onUpdate: ({ editor }) => {
|
||||
this.value = editor.getHTML();
|
||||
this.updateWordCount();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('input', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
this.requestUpdate();
|
||||
},
|
||||
onFocus: () => {
|
||||
this.requestUpdate();
|
||||
},
|
||||
onBlur: () => {
|
||||
this.requestUpdate();
|
||||
},
|
||||
});
|
||||
|
||||
this.updateWordCount();
|
||||
}
|
||||
|
||||
private updateWordCount(): void {
|
||||
if (!this.editor) return;
|
||||
const text = this.editor.getText();
|
||||
this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
private toggleLink(): void {
|
||||
if (!this.editor) return;
|
||||
|
||||
if (this.editor.isActive('link')) {
|
||||
const href = this.editor.getAttributes('link').href;
|
||||
this.showLinkInput = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (this.linkInputElement) {
|
||||
this.linkInputElement.value = href || '';
|
||||
this.linkInputElement.focus();
|
||||
this.linkInputElement.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.showLinkInput = true;
|
||||
requestAnimationFrame(() => {
|
||||
if (this.linkInputElement) {
|
||||
this.linkInputElement.value = '';
|
||||
this.linkInputElement.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public saveLink(): void {
|
||||
if (!this.editor || !this.linkInputElement) return;
|
||||
|
||||
const url = this.linkInputElement.value;
|
||||
if (url) {
|
||||
this.editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
this.hideLinkInput();
|
||||
}
|
||||
|
||||
public removeLink(): void {
|
||||
if (!this.editor) return;
|
||||
this.editor.chain().focus().unsetLink().run();
|
||||
this.hideLinkInput();
|
||||
}
|
||||
|
||||
public hideLinkInput(): void {
|
||||
this.showLinkInput = false;
|
||||
this.editor?.commands.focus();
|
||||
}
|
||||
|
||||
public handleLinkInputKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.saveLink();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.hideLinkInput();
|
||||
}
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
if (this.editor && value !== this.editor.getHTML()) {
|
||||
this.editor.commands.setContent(value);
|
||||
}
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.setValue('');
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this.editor?.commands.focus();
|
||||
}
|
||||
|
||||
public async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
134
ts_web/elements/00group-input/dees-input-richtext/demo.ts
Normal file
134
ts_web/elements/00group-input/dees-input-richtext/demo.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import './component.js';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.output-preview {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.output-preview {
|
||||
background: #2c2c2c;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic Rich Text Editor'} .subtitle=${'A full-featured rich text editor with formatting toolbar'}>
|
||||
<dees-input-richtext
|
||||
.label=${'Article Content'}
|
||||
.value=${'<h1>Welcome to the Rich Text Editor!</h1><p>This is a feature-rich editor built with TipTap. You can:</p><ul><li><strong>Format text</strong> with <em>various</em> <u>styles</u></li><li>Create different heading levels</li><li>Add <a href="https://example.com">links</a> to external resources</li><li>Write <code>inline code</code> or code blocks</li></ul><blockquote><p>Use the toolbar above to explore all the formatting options available!</p></blockquote><p>Start typing to see the magic happen...</p>'}
|
||||
.description=${'Use the toolbar to format your content with headings, lists, links, and more'}
|
||||
.showWordCount=${true}
|
||||
></dees-input-richtext>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. With Placeholder'} .subtitle=${'Empty editor with placeholder text'}>
|
||||
<dees-input-richtext
|
||||
.label=${'Blog Post'}
|
||||
.placeholder=${'Start writing your blog post here...'}
|
||||
.showWordCount=${true}
|
||||
></dees-input-richtext>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Different Heights'} .subtitle=${'Editors with different minimum heights for various use cases'}>
|
||||
<div class="grid-layout">
|
||||
<dees-input-richtext
|
||||
.label=${'Short Note'}
|
||||
.minHeight=${150}
|
||||
.placeholder=${'Quick note...'}
|
||||
.showWordCount=${false}
|
||||
></dees-input-richtext>
|
||||
|
||||
<dees-input-richtext
|
||||
.label=${'Extended Content'}
|
||||
.minHeight=${300}
|
||||
.placeholder=${'Write your extended content here...'}
|
||||
.showWordCount=${true}
|
||||
></dees-input-richtext>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. Code Examples'} .subtitle=${'Editor pre-filled with code examples'}>
|
||||
<dees-input-richtext
|
||||
.label=${'Technical Documentation'}
|
||||
.value=${'<h2>Installation Guide</h2><p>To install the package, run the following command:</p><pre><code>npm install @design.estate/dees-catalog</code></pre><p>Then import the component in your TypeScript file:</p><pre><code>import { DeesInputRichtext } from "@design.estate/dees-catalog";</code></pre><p>You can now use the <code><dees-input-richtext></code> element in your templates.</p>'}
|
||||
.minHeight=${250}
|
||||
.showWordCount=${true}
|
||||
></dees-input-richtext>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only rich text content'}>
|
||||
<dees-input-richtext
|
||||
.label=${'Published Article (Read Only)'}
|
||||
.value=${'<h2>The Future of Web Components</h2><p>Web Components have revolutionized how we build modern web applications...</p><blockquote><p>"The future of web development lies in reusable, encapsulated components."</p></blockquote>'}
|
||||
.disabled=${true}
|
||||
.showWordCount=${true}
|
||||
></dees-input-richtext>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'6. Interactive Demo'} .subtitle=${'Type in the editor below and see the HTML output'}>
|
||||
<dees-input-richtext
|
||||
id="interactive-editor"
|
||||
.label=${'Try it yourself'}
|
||||
.placeholder=${'Type something here...'}
|
||||
.showWordCount=${true}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const output = document.querySelector('#output-preview');
|
||||
if (output) {
|
||||
output.textContent = e.detail.value;
|
||||
}
|
||||
}}
|
||||
></dees-input-richtext>
|
||||
|
||||
<div class="output-preview" id="output-preview">
|
||||
<em>HTML output will appear here...</em>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './component.js';
|
||||
303
ts_web/elements/00group-input/dees-input-richtext/styles.ts
Normal file
303
ts_web/elements/00group-input/dees-input-richtext/styles.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
|
||||
export const richtextStyles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: ${cssManager.bdTheme('200px', '200px')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
overflow: hidden;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.editor-container:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.editor-container.focused {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar-button dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.toolbar-button.active {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
}
|
||||
|
||||
.toolbar-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
min-height: var(--min-height, 200px);
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror {
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin: 1em 0 0.5em 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin: 1em 0 0.5em 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: bold;
|
||||
margin: 1em 0 0.5em 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror ul,
|
||||
.editor-content .ProseMirror ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror blockquote {
|
||||
border-left: 4px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror code {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 3px;
|
||||
padding: 0.2em 0.4em;
|
||||
font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror pre {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
|
||||
border-radius: 6px;
|
||||
padding: 1em;
|
||||
margin: 1em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror pre code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror a {
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-content .ProseMirror a:hover {
|
||||
color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')};
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
padding: 12px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.link-input.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.link-input input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.link-input input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
|
||||
}
|
||||
|
||||
.link-input-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.link-input-buttons button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link-input-buttons button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.link-input-buttons button.primary {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
}
|
||||
|
||||
.link-input-buttons button.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:host([disabled]) .editor-container {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:host([disabled]) .toolbar-button,
|
||||
:host([disabled]) .editor-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesInputRichtext } from './component.js';
|
||||
|
||||
export const renderRichtext = (component: DeesInputRichtext): TemplateResult => {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${component.label ? html`<label class="label">${component.label}</label>` : ''}
|
||||
<div class="editor-container ${component.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${component.minHeight}px">
|
||||
<div class="editor-toolbar">
|
||||
${component.renderToolbar()}
|
||||
<div class="link-input ${component.showLinkInput ? 'show' : ''}">
|
||||
<input type="url" placeholder="Enter URL..." @keydown=${component.handleLinkInputKeydown} />
|
||||
<div class="link-input-buttons">
|
||||
<button class="primary" @click=${component.saveLink}>Save</button>
|
||||
<button @click=${component.removeLink}>Remove</button>
|
||||
<button @click=${component.hideLinkInput}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content"></div>
|
||||
${component.showWordCount
|
||||
? html`
|
||||
<div class="editor-footer">
|
||||
<span class="word-count">${component.wordCount} word${component.wordCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
${component.description ? html`<div class="description">${component.description}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface IToolbarButton {
|
||||
name: string;
|
||||
icon?: string;
|
||||
action?: () => void;
|
||||
isActive?: () => boolean;
|
||||
title: string;
|
||||
isDivider?: boolean;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-searchselect.js';
|
||||
@@ -0,0 +1,248 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.output-preview {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.output-preview {
|
||||
background: #2c2c2c;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tag-preview {
|
||||
background: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-preview-item {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tag-preview-item {
|
||||
background: #312e81;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic Tags Input'} .subtitle=${'Simple tag input with common programming languages'}>
|
||||
<dees-input-tags
|
||||
.label=${'Programming Languages'}
|
||||
.placeholder=${'Add a language...'}
|
||||
.value=${['JavaScript', 'TypeScript', 'Python', 'Go']}
|
||||
.description=${'Press Enter or comma to add tags'}
|
||||
></dees-input-tags>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. Tags with Suggestions'} .subtitle=${'Auto-complete suggestions for faster input'}>
|
||||
<dees-input-tags
|
||||
.label=${'Tech Stack'}
|
||||
.placeholder=${'Type to see suggestions...'}
|
||||
.suggestions=${[
|
||||
'React', 'Vue', 'Angular', 'Svelte', 'Lit', 'Next.js', 'Nuxt', 'SvelteKit',
|
||||
'Node.js', 'Deno', 'Bun', 'Express', 'Fastify', 'Nest.js', 'Koa',
|
||||
'MongoDB', 'PostgreSQL', 'Redis', 'MySQL', 'SQLite', 'Cassandra',
|
||||
'Docker', 'Kubernetes', 'AWS', 'Azure', 'GCP', 'Vercel', 'Netlify'
|
||||
]}
|
||||
.value=${['React', 'Node.js', 'PostgreSQL', 'Docker']}
|
||||
.description=${'Start typing to see suggestions from popular technologies'}
|
||||
></dees-input-tags>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Limited Tags'} .subtitle=${'Restrict the number of tags users can add'}>
|
||||
<div class="grid-layout">
|
||||
<dees-input-tags
|
||||
.label=${'Top 3 Skills'}
|
||||
.placeholder=${'Add up to 3 skills...'}
|
||||
.maxTags=${3}
|
||||
.value=${['Design', 'Development']}
|
||||
.description=${'Maximum 3 tags allowed'}
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-tags
|
||||
.label=${'Categories (Max 5)'}
|
||||
.placeholder=${'Select categories...'}
|
||||
.maxTags=${5}
|
||||
.suggestions=${['Blog', 'Tutorial', 'News', 'Review', 'Guide', 'Case Study', 'Interview']}
|
||||
.value=${['Tutorial', 'Guide']}
|
||||
.description=${'Choose up to 5 categories'}
|
||||
></dees-input-tags>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. Required & Validation'} .subtitle=${'Tags input with validation requirements'}>
|
||||
<dees-input-tags
|
||||
.label=${'Project Tags'}
|
||||
.placeholder=${'Add at least one tag...'}
|
||||
.required=${true}
|
||||
.description=${'This field is required - add at least one tag'}
|
||||
></dees-input-tags>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. Disabled State'} .subtitle=${'Read-only tags display'}>
|
||||
<dees-input-tags
|
||||
.label=${'System Tags'}
|
||||
.value=${['System', 'Protected', 'Read-Only', 'Archive']}
|
||||
.disabled=${true}
|
||||
.description=${'These tags cannot be modified'}
|
||||
></dees-input-tags>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'6. Form Integration'} .subtitle=${'Tags input working within a form context'}>
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.label=${'Project Name'}
|
||||
.placeholder=${'My Awesome Project'}
|
||||
.required=${true}
|
||||
.key=${'name'}
|
||||
></dees-input-text>
|
||||
|
||||
<div class="grid-layout">
|
||||
<dees-input-tags
|
||||
.label=${'Technologies Used'}
|
||||
.placeholder=${'Add technologies...'}
|
||||
.required=${true}
|
||||
.key=${'technologies'}
|
||||
.suggestions=${[
|
||||
'TypeScript', 'JavaScript', 'Python', 'Go', 'Rust',
|
||||
'React', 'Vue', 'Angular', 'Svelte',
|
||||
'Node.js', 'Deno', 'Express', 'FastAPI'
|
||||
]}
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-tags
|
||||
.label=${'Project Tags'}
|
||||
.placeholder=${'Add descriptive tags...'}
|
||||
.key=${'tags'}
|
||||
.maxTags=${10}
|
||||
.suggestions=${[
|
||||
'frontend', 'backend', 'fullstack', 'mobile', 'desktop',
|
||||
'web', 'api', 'database', 'devops', 'ui/ux',
|
||||
'opensource', 'saas', 'enterprise', 'startup'
|
||||
]}
|
||||
></dees-input-tags>
|
||||
</div>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Description'}
|
||||
.inputType=${'textarea'}
|
||||
.placeholder=${'Describe your project...'}
|
||||
.key=${'description'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Create Project'}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'7. Interactive Demo'} .subtitle=${'Add tags and see them collected in real-time'}>
|
||||
<dees-input-tags
|
||||
id="interactive-tags"
|
||||
.label=${'Your Interests'}
|
||||
.placeholder=${'Type your interests...'}
|
||||
.suggestions=${[
|
||||
'Music', 'Movies', 'Books', 'Travel', 'Photography',
|
||||
'Cooking', 'Gaming', 'Sports', 'Art', 'Technology',
|
||||
'Fashion', 'Fitness', 'Nature', 'Science', 'History'
|
||||
]}
|
||||
@change=${(e: CustomEvent) => {
|
||||
const preview = document.querySelector('#tags-preview');
|
||||
const tags = e.detail.value;
|
||||
if (preview) {
|
||||
if (tags.length === 0) {
|
||||
preview.innerHTML = '<em style="color: #999;">No tags added yet...</em>';
|
||||
} else {
|
||||
preview.innerHTML = tags.map((tag: string) =>
|
||||
`<span class="tag-preview-item">${tag}</span>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
></dees-input-tags>
|
||||
|
||||
<div class="tag-preview" id="tags-preview">
|
||||
<em style="color: #999;">No tags added yet...</em>
|
||||
</div>
|
||||
|
||||
<div class="output-preview" id="tags-json">
|
||||
<em>JSON output will appear here...</em>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update JSON preview
|
||||
const tagsInput = document.querySelector('#interactive-tags');
|
||||
tagsInput?.addEventListener('change', (e) => {
|
||||
const jsonPreview = document.querySelector('#tags-json');
|
||||
if (jsonPreview) {
|
||||
jsonPreview.textContent = JSON.stringify(e.detail.value, null, 2);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
431
ts_web/elements/00group-input/dees-input-tags/dees-input-tags.ts
Normal file
431
ts_web/elements/00group-input/dees-input-tags/dees-input-tags.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import { demoFunc } from './dees-input-tags.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-tags': DeesInputTags;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-tags')
|
||||
export class DeesInputTags extends DeesInputBase<DeesInputTags> {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
accessor value: string[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = 'Add tags...';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxTags: number = 0; // 0 means unlimited
|
||||
|
||||
@property({ type: Array })
|
||||
accessor suggestions: string[] = [];
|
||||
|
||||
@state()
|
||||
accessor inputValue: string = '';
|
||||
|
||||
@state()
|
||||
accessor showSuggestions: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor highlightedSuggestionIndex: number = -1;
|
||||
|
||||
@property({ type: String })
|
||||
accessor validationText: string = '';
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
min-height: 40px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tags-container:hover:not(.disabled) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.tags-container:focus-within {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
|
||||
}
|
||||
|
||||
.tags-container.disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: ${cssManager.bdTheme('hsl(215 20.2% 65.1% / 0.2)', 'hsl(215 20.2% 35.1% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1% / 0.3)', 'hsl(215 20.2% 35.1% / 0.3)')};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
user-select: none;
|
||||
animation: tagAppear 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes tagAppear {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 46.7%)', 'hsl(217.9 10.6% 54.9%)')};
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.08)', 'hsl(0 0% 100% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(217.9 10.6% 74.9%)')};
|
||||
}
|
||||
|
||||
.tag-remove dees-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
padding: 2px 4px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tag-input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
|
||||
}
|
||||
|
||||
.tag-input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Suggestions dropdown */
|
||||
.suggestions-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.suggestions-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
.suggestion:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.suggestion.highlighted {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
/* Validation styles */
|
||||
.validation-message {
|
||||
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Description styles */
|
||||
.description {
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.suggestions-dropdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filteredSuggestions = this.suggestions.filter(
|
||||
suggestion =>
|
||||
!this.value.includes(suggestion) &&
|
||||
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
|
||||
|
||||
<div class="suggestions-container">
|
||||
<div
|
||||
class="tags-container ${this.disabled ? 'disabled' : ''}"
|
||||
@click=${this.handleContainerClick}
|
||||
>
|
||||
${this.value.map(tag => html`
|
||||
<div class="tag">
|
||||
<span>${tag}</span>
|
||||
${!this.disabled ? html`
|
||||
<div class="tag-remove" @click=${(e: Event) => this.removeTag(e, tag)}>
|
||||
<dees-icon .icon=${'lucide:x'}></dees-icon>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`)}
|
||||
|
||||
${!this.disabled && (!this.maxTags || this.value.length < this.maxTags) ? html`
|
||||
<input
|
||||
type="text"
|
||||
class="tag-input"
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.inputValue}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
@focus=${this.handleFocus}
|
||||
@blur=${this.handleBlur}
|
||||
?disabled=${this.disabled}
|
||||
/>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this.showSuggestions && filteredSuggestions.length > 0 ? html`
|
||||
<div class="suggestions-dropdown">
|
||||
${filteredSuggestions.map((suggestion, index) => html`
|
||||
<div
|
||||
class="suggestion ${index === this.highlightedSuggestionIndex ? 'highlighted' : ''}"
|
||||
@mousedown=${(e: Event) => {
|
||||
e.preventDefault(); // Prevent blur
|
||||
this.addTag(suggestion);
|
||||
}}
|
||||
@mouseenter=${() => this.highlightedSuggestionIndex = index}
|
||||
>
|
||||
${suggestion}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${this.validationText ? html`
|
||||
<div class="validation-message">${this.validationText}</div>
|
||||
` : ''}
|
||||
|
||||
${this.description ? html`
|
||||
<div class="description">${this.description}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleContainerClick(e: Event) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
|
||||
if (input && e.target !== input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.inputValue = input.value;
|
||||
|
||||
// Check for comma or semicolon to add tag
|
||||
if (this.inputValue.includes(',') || this.inputValue.includes(';')) {
|
||||
const tag = this.inputValue.replace(/[,;]/g, '').trim();
|
||||
if (tag) {
|
||||
this.addTag(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyDown(e: KeyboardEvent) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (this.highlightedSuggestionIndex >= 0 && this.showSuggestions) {
|
||||
const filteredSuggestions = this.suggestions.filter(
|
||||
suggestion =>
|
||||
!this.value.includes(suggestion) &&
|
||||
suggestion.toLowerCase().includes(this.inputValue.toLowerCase())
|
||||
);
|
||||
if (filteredSuggestions[this.highlightedSuggestionIndex]) {
|
||||
this.addTag(filteredSuggestions[this.highlightedSuggestionIndex]);
|
||||
}
|
||||
} else if (this.inputValue.trim()) {
|
||||
this.addTag(this.inputValue.trim());
|
||||
}
|
||||
} else if (e.key === 'Backspace' && !this.inputValue && this.value.length > 0) {
|
||||
// Remove last tag when backspace is pressed on empty input
|
||||
this.removeTag(e, this.value[this.value.length - 1]);
|
||||
} else if (e.key === 'ArrowDown' && this.showSuggestions) {
|
||||
e.preventDefault();
|
||||
const filteredCount = this.suggestions.filter(
|
||||
s => !this.value.includes(s) && s.toLowerCase().includes(this.inputValue.toLowerCase())
|
||||
).length;
|
||||
this.highlightedSuggestionIndex = Math.min(
|
||||
this.highlightedSuggestionIndex + 1,
|
||||
filteredCount - 1
|
||||
);
|
||||
} else if (e.key === 'ArrowUp' && this.showSuggestions) {
|
||||
e.preventDefault();
|
||||
this.highlightedSuggestionIndex = Math.max(this.highlightedSuggestionIndex - 1, 0);
|
||||
} else if (e.key === 'Escape') {
|
||||
this.showSuggestions = false;
|
||||
this.highlightedSuggestionIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFocus() {
|
||||
if (this.suggestions.length > 0) {
|
||||
this.showSuggestions = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBlur() {
|
||||
// Delay to allow click on suggestions
|
||||
setTimeout(() => {
|
||||
this.showSuggestions = false;
|
||||
this.highlightedSuggestionIndex = -1;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private addTag(tag: string) {
|
||||
if (!tag || this.value.includes(tag)) return;
|
||||
if (this.maxTags && this.value.length >= this.maxTags) return;
|
||||
|
||||
this.value = [...this.value, tag];
|
||||
this.inputValue = '';
|
||||
this.showSuggestions = false;
|
||||
this.highlightedSuggestionIndex = -1;
|
||||
|
||||
// Clear the input
|
||||
const input = this.shadowRoot?.querySelector('.tag-input') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private removeTag(e: Event, tag: string) {
|
||||
e.stopPropagation();
|
||||
this.value = this.value.filter(t => t !== tag);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private emitChange() {
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: { value: this.value },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string[]): void {
|
||||
this.value = value || [];
|
||||
}
|
||||
|
||||
public async validate(): Promise<boolean> {
|
||||
if (this.required && (!this.value || this.value.length === 0)) {
|
||||
this.validationText = 'At least one tag is required';
|
||||
return false;
|
||||
}
|
||||
this.validationText = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-tags/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-tags/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-tags.js';
|
||||
@@ -0,0 +1,339 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import type { DeesInputText } from '../dees-input-text/dees-input-text.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
dees-panel:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.interactive-section {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(215 20.2% 16.8%)')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215.3 25% 26.7%)', 'hsl(210 40% 80%)')};
|
||||
padding: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate basic text input functionality
|
||||
const inputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
inputs.forEach((input: DeesInputText) => {
|
||||
input.addEventListener('changeSubject', (event: CustomEvent) => {
|
||||
console.log(`Input "${input.label}" changed to:`, input.getValue());
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
console.log(`Input "${input.label}" lost focus`);
|
||||
});
|
||||
});
|
||||
|
||||
// Show password visibility toggle
|
||||
const passwordInput = elementArg.querySelector('dees-input-text[key="password"]') as DeesInputText;
|
||||
if (passwordInput) {
|
||||
console.log('Password input includes visibility toggle');
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Basic Text Inputs'} .subtitle=${'Standard text inputs with labels and descriptions'}>
|
||||
<dees-input-text
|
||||
.label=${'Username'}
|
||||
.value=${'johndoe'}
|
||||
.key=${'username'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Email Address'}
|
||||
.value=${'john@example.com'}
|
||||
.description=${'We will never share your email with anyone'}
|
||||
.key=${'email'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Password'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${'secret123'}
|
||||
.key=${'password'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate horizontal layout behavior
|
||||
const horizontalInputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
// Check that inputs are properly spaced horizontally
|
||||
horizontalInputs.forEach((input: DeesInputText) => {
|
||||
const computedStyle = window.getComputedStyle(input);
|
||||
console.log(`Horizontal input "${input.label}" display:`, computedStyle.display);
|
||||
});
|
||||
|
||||
// Track value changes
|
||||
const firstNameInput = elementArg.querySelector('dees-input-text[key="firstName"]');
|
||||
const lastNameInput = elementArg.querySelector('dees-input-text[key="lastName"]');
|
||||
|
||||
if (firstNameInput && lastNameInput) {
|
||||
const updateFullName = () => {
|
||||
const firstName = (firstNameInput as DeesInputText).getValue();
|
||||
const lastName = (lastNameInput as DeesInputText).getValue();
|
||||
console.log(`Full name: ${firstName} ${lastName}`);
|
||||
};
|
||||
|
||||
firstNameInput.addEventListener('changeSubject', updateFullName);
|
||||
lastNameInput.addEventListener('changeSubject', updateFullName);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Horizontal Layout'} .subtitle=${'Multiple inputs arranged horizontally for compact forms'}>
|
||||
<div class="horizontal-group">
|
||||
<dees-input-text
|
||||
.label=${'First Name'}
|
||||
.value=${'John'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'firstName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Last Name'}
|
||||
.value=${'Doe'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'lastName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Age'}
|
||||
.value=${'28'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.key=${'age'}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate different label positions
|
||||
const inputs = elementArg.querySelectorAll('dees-input-text');
|
||||
|
||||
inputs.forEach((input: DeesInputText) => {
|
||||
const position = input.labelPosition;
|
||||
console.log(`Input "${input.label}" has label position: ${position}`);
|
||||
});
|
||||
|
||||
// Show how label position affects layout
|
||||
const leftLabelInputs = elementArg.querySelectorAll('dees-input-text[labelPosition="left"]');
|
||||
if (leftLabelInputs.length > 0) {
|
||||
console.log(`${leftLabelInputs.length} inputs have left-aligned labels for inline layout`);
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Label Positions'} .subtitle=${'Different label positioning options for various layouts'}>
|
||||
<dees-input-text
|
||||
.label=${'Label on Top (Default)'}
|
||||
.value=${'Standard layout'}
|
||||
.labelPosition=${'top'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Label on Left'}
|
||||
.value=${'Inline label'}
|
||||
.labelPosition=${'left'}
|
||||
></dees-input-text>
|
||||
|
||||
<div class="grid-layout">
|
||||
<dees-input-text
|
||||
.label=${'City'}
|
||||
.value=${'New York'}
|
||||
.labelPosition=${'left'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'ZIP Code'}
|
||||
.value=${'10001'}
|
||||
.labelPosition=${'left'}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Demonstrate validation states
|
||||
const requiredInput = elementArg.querySelector('dees-input-text[required]') as DeesInputText;
|
||||
const disabledInput = elementArg.querySelector('dees-input-text[disabled]') as DeesInputText;
|
||||
const errorInput = elementArg.querySelector('dees-input-text[validationState="invalid"]') as DeesInputText;
|
||||
|
||||
if (requiredInput) {
|
||||
// Show validation on blur for empty required field
|
||||
requiredInput.addEventListener('blur', () => {
|
||||
if (!requiredInput.getValue()) {
|
||||
console.log('Required field is empty!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (disabledInput) {
|
||||
console.log('Disabled input cannot be edited');
|
||||
}
|
||||
|
||||
if (errorInput) {
|
||||
console.log('Error input shows validation message:', errorInput.validationText);
|
||||
|
||||
// Simulate fixing the error
|
||||
errorInput.addEventListener('changeSubject', () => {
|
||||
const value = errorInput.getValue();
|
||||
if (value.includes('@') && value.includes('.')) {
|
||||
errorInput.validationState = 'valid';
|
||||
errorInput.validationText = '';
|
||||
console.log('Email validation passed!');
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Validation & States'} .subtitle=${'Different validation states and input configurations'}>
|
||||
<dees-input-text
|
||||
.label=${'Required Field'}
|
||||
.required=${true}
|
||||
.key=${'requiredField'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Disabled Field'}
|
||||
.value=${'Cannot edit this'}
|
||||
.disabled=${true}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Field with Error'}
|
||||
.value=${'invalid@'}
|
||||
.validationText=${'Please enter a valid email address'}
|
||||
.validationState=${'invalid'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Track password visibility toggles
|
||||
const passwordInputs = elementArg.querySelectorAll('dees-input-text[isPasswordBool]');
|
||||
|
||||
passwordInputs.forEach((input: DeesInputText) => {
|
||||
// Monitor for toggle button clicks within shadow DOM
|
||||
const checkToggle = () => {
|
||||
const inputEl = input.shadowRoot?.querySelector('input');
|
||||
if (inputEl) {
|
||||
console.log(`Password field "${input.label}" type:`, inputEl.type);
|
||||
}
|
||||
};
|
||||
|
||||
// Use MutationObserver to detect changes
|
||||
if (input.shadowRoot) {
|
||||
const observer = new MutationObserver(checkToggle);
|
||||
const inputEl = input.shadowRoot.querySelector('input');
|
||||
if (inputEl) {
|
||||
observer.observe(inputEl, { attributes: true, attributeFilter: ['type'] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Advanced Features'} .subtitle=${'Password visibility toggle and other advanced features'}>
|
||||
<dees-input-text
|
||||
.label=${'Password with Toggle'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${'mySecurePassword123'}
|
||||
.description=${'Click the eye icon to show/hide password'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'API Key'}
|
||||
.isPasswordBool=${true}
|
||||
.value=${'sk-1234567890abcdef'}
|
||||
.description=${'Keep this key secure and never share it'}
|
||||
></dees-input-text>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Set up interactive example
|
||||
const dynamicInput = elementArg.querySelector('dees-input-text');
|
||||
const output = elementArg.querySelector('#text-input-output');
|
||||
|
||||
if (dynamicInput && output) {
|
||||
// Update output on every change
|
||||
dynamicInput.addEventListener('changeSubject', (event: CustomEvent) => {
|
||||
const value = (event.detail as DeesInputText).getValue();
|
||||
output.textContent = `Current value: "${value}"`;
|
||||
});
|
||||
|
||||
// Also track focus/blur events
|
||||
dynamicInput.addEventListener('focus', () => {
|
||||
console.log('Input focused');
|
||||
});
|
||||
|
||||
dynamicInput.addEventListener('blur', () => {
|
||||
console.log('Input blurred');
|
||||
});
|
||||
|
||||
// Track keypress events
|
||||
let keypressCount = 0;
|
||||
dynamicInput.addEventListener('keydown', () => {
|
||||
keypressCount++;
|
||||
console.log(`Keypress count: ${keypressCount}`);
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Interactive Example'} .subtitle=${'Try typing in the inputs to see real-time value changes'}>
|
||||
<dees-input-text
|
||||
.label=${'Dynamic Input'}
|
||||
.placeholder=${'Type something here...'}
|
||||
></dees-input-text>
|
||||
|
||||
<div class="interactive-section">
|
||||
<div id="text-input-output" class="output-text">Current value: ""</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
||||
281
ts_web/elements/00group-input/dees-input-text/dees-input-text.ts
Normal file
281
ts_web/elements/00group-input/dees-input-text/dees-input-text.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import * as colors from '../../00colors.js';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import { demoFunc } from './dees-input-text.demo.js';
|
||||
import { cssGeistFontFamily, cssMonoFontFamily } from '../../00fonts.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
html,
|
||||
cssManager,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-text': DeesInputText;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-input-text')
|
||||
export class DeesInputText extends DeesInputBase {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: String,
|
||||
reflect: true,
|
||||
})
|
||||
accessor value: string = '';
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
})
|
||||
accessor isPasswordBool = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
})
|
||||
accessor showPasswordBool = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
})
|
||||
accessor validationState: 'valid' | 'warn' | 'invalid';
|
||||
|
||||
@property({
|
||||
reflect: true,
|
||||
})
|
||||
accessor validationText: string = '';
|
||||
|
||||
@property({})
|
||||
accessor validationFunction: (value: string) => boolean;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
|
||||
.maincontainer {
|
||||
position: relative;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
|
||||
input {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 40px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
cursor: text;
|
||||
font-family: inherit;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
}
|
||||
|
||||
input:hover:not(:disabled):not(:focus) {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Password toggle button */
|
||||
.showPassword {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
cursor: pointer;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
transition: all 0.15s ease;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.showPassword:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
|
||||
}
|
||||
|
||||
/* Validation styles */
|
||||
.validationContainer {
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.validationContainer.error {
|
||||
background: ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.1)', 'hsl(0 72.2% 50.6% / 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
}
|
||||
|
||||
.validationContainer.warn {
|
||||
background: ${cssManager.bdTheme('hsl(25 95% 53% / 0.1)', 'hsl(25 95% 63% / 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
}
|
||||
|
||||
.validationContainer.valid {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 70.6% 45.3% / 0.1)')};
|
||||
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
}
|
||||
|
||||
/* Error state for input */
|
||||
:host([validation-state="invalid"]) input {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
}
|
||||
|
||||
:host([validation-state="invalid"]) input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 84.2% 60.2%)', 'hsl(0 72.2% 50.6%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 84.2% 60.2% / 0.05)', 'hsl(0 72.2% 50.6% / 0.05)')};
|
||||
}
|
||||
|
||||
/* Warning state for input */
|
||||
:host([validation-state="warn"]) input {
|
||||
border-color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
}
|
||||
|
||||
:host([validation-state="warn"]) input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(25 95% 53%)', 'hsl(25 95% 63%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(25 95% 53% / 0.05)', 'hsl(25 95% 63% / 0.05)')};
|
||||
}
|
||||
|
||||
/* Valid state for input */
|
||||
:host([validation-state="valid"]) input {
|
||||
border-color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
}
|
||||
|
||||
:host([validation-state="valid"]) input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.05)', 'hsl(142.1 70.6% 45.3% / 0.05)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<style>
|
||||
input {
|
||||
font-family: ${this.isPasswordBool ? cssMonoFontFamily : 'inherit'};
|
||||
letter-spacing: ${this.isPasswordBool ? '0.5px' : 'normal'};
|
||||
padding-right: ${this.isPasswordBool ? '48px' : '12px'};
|
||||
}
|
||||
${this.validationText
|
||||
? css`
|
||||
.validationContainer {
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`
|
||||
: css`
|
||||
.validationContainer {
|
||||
height: 0;
|
||||
padding: 0 !important;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||
<div class="maincontainer">
|
||||
<input
|
||||
type="${this.isPasswordBool && !this.showPasswordBool ? 'password' : 'text'}"
|
||||
.value=${this.value}
|
||||
@input="${this.updateValue}"
|
||||
.disabled=${this.disabled}
|
||||
placeholder="${this.label ? '' : 'Enter text...'}"
|
||||
/>
|
||||
${this.isPasswordBool
|
||||
? html`
|
||||
<div class="showPassword" @click=${this.togglePasswordView}>
|
||||
<dees-icon .icon=${this.showPasswordBool ? 'lucide:Eye' : 'lucide:EyeOff'}></dees-icon>
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
${this.validationText
|
||||
? html`
|
||||
<div class="validationContainer ${this.validationState || 'error'}">
|
||||
${this.validationText}
|
||||
</div>
|
||||
`
|
||||
: html`<div class="validationContainer"></div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
// Input event handling is already done in updateValue method
|
||||
}
|
||||
|
||||
public async updateValue(eventArg: Event) {
|
||||
const target: any = eventArg.target;
|
||||
this.value = target.value;
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public async togglePasswordView() {
|
||||
this.showPasswordBool = !this.showPasswordBool;
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
const textInput = this.shadowRoot.querySelector('input');
|
||||
textInput.focus();
|
||||
}
|
||||
|
||||
public async blur() {
|
||||
const textInput = this.shadowRoot.querySelector('input');
|
||||
textInput.blur();
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-input/dees-input-text/index.ts
Normal file
1
ts_web/elements/00group-input/dees-input-text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-input-text.js';
|
||||
@@ -0,0 +1,118 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.info-box {
|
||||
background: #1e3a5f;
|
||||
color: #90caf9;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'Basic Type List'} .subtitle=${'Add and remove items from a list'}>
|
||||
<dees-input-typelist
|
||||
.label=${'Tags'}
|
||||
.description=${'Add tags by typing and pressing Enter'}
|
||||
.value=${['javascript', 'typescript', 'web-components']}
|
||||
></dees-input-typelist>
|
||||
|
||||
<dees-input-typelist
|
||||
.label=${'Team Members'}
|
||||
.description=${'Add email addresses of team members'}
|
||||
.value=${['alice@example.com', 'bob@example.com']}
|
||||
></dees-input-typelist>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Skills & Keywords'} .subtitle=${'Manage lists of skills and keywords'}>
|
||||
<dees-input-typelist
|
||||
.label=${'Your Skills'}
|
||||
.description=${'List your professional skills'}
|
||||
.value=${['HTML', 'CSS', 'JavaScript', 'Node.js', 'React']}
|
||||
></dees-input-typelist>
|
||||
|
||||
<div class="horizontal-group">
|
||||
<dees-input-typelist
|
||||
.label=${'Categories'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${['Technology', 'Design', 'Business']}
|
||||
></dees-input-typelist>
|
||||
|
||||
<dees-input-typelist
|
||||
.label=${'Keywords'}
|
||||
.layoutMode=${'horizontal'}
|
||||
.value=${['innovation', 'startup', 'growth']}
|
||||
></dees-input-typelist>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Required & Disabled States'} .subtitle=${'Different input states for validation'}>
|
||||
<dees-input-typelist
|
||||
.label=${'Project Dependencies'}
|
||||
.description=${'List all required npm packages'}
|
||||
.required=${true}
|
||||
.value=${['@design.estate/dees-element', '@design.estate/dees-domtools']}
|
||||
></dees-input-typelist>
|
||||
|
||||
<dees-input-typelist
|
||||
.label=${'System Tags'}
|
||||
.description=${'These tags are managed by the system'}
|
||||
.disabled=${true}
|
||||
.value=${['system', 'protected', 'readonly']}
|
||||
></dees-input-typelist>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Article Publishing Form'} .subtitle=${'Complete form with tag management'}>
|
||||
<dees-form>
|
||||
<dees-input-text .label=${'Article Title'} .required=${true}></dees-input-text>
|
||||
<dees-input-text
|
||||
.label=${'Summary'}
|
||||
.inputType=${'textarea'}
|
||||
.description=${'Brief description of the article'}
|
||||
></dees-input-text>
|
||||
<dees-input-typelist
|
||||
.label=${'Tags'}
|
||||
.description=${'Add relevant tags for better discoverability'}
|
||||
.value=${['tutorial', 'web-development']}
|
||||
></dees-input-typelist>
|
||||
<dees-input-typelist
|
||||
.label=${'Co-Authors'}
|
||||
.description=${'Add email addresses of co-authors'}
|
||||
></dees-input-typelist>
|
||||
</dees-form>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Tip:</strong> Type a value and press Enter to add it to the list. Click on any item to remove it.
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
state,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
|
||||
import { demoFunc } from './dees-input-typelist.demo.js';
|
||||
|
||||
@customElement('dees-input-typelist')
|
||||
export class DeesInputTypelist extends DeesInputBase<DeesInputTypelist> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
|
||||
// INSTANCE
|
||||
|
||||
@property({ type: Array })
|
||||
accessor value: string[] = [];
|
||||
|
||||
@state()
|
||||
accessor inputValue: string = '';
|
||||
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
}
|
||||
.mainbox {
|
||||
border-radius: 3px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#222222')};
|
||||
overflow: hidden;
|
||||
border-top: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
|
||||
border-bottom: ${cssManager.bdTheme('1px solid #CCC', '1px solid #222')};
|
||||
border-right: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
|
||||
border-left: ${cssManager.bdTheme('1px solid #CCC', '1px solid #ffffff10')};
|
||||
box-shadow: ${cssManager.bdTheme('0px 1px 4px rgba(0,0,0,0.3)', 'none')};
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mainbox:hover {
|
||||
filter: ${cssManager.bdTheme('brightness(0.98)', 'brightness(1.05)')};
|
||||
}
|
||||
|
||||
.mainbox:focus-within {
|
||||
outline: 2px solid ${cssManager.bdTheme('#0069f2', '#0084ff')};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
padding: 16px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notags {
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#181818')};
|
||||
width: 100%;
|
||||
outline: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
padding: 0px 16px;
|
||||
overflow: hidden;
|
||||
line-height: 32px;
|
||||
height: 0px;
|
||||
transition: height 0.2s;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
}
|
||||
|
||||
input:focus {
|
||||
height: 32px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: ${cssManager.bdTheme('#e8f5e9', '#2d3a2d')};
|
||||
color: ${cssManager.bdTheme('#2e7d32', '#81c784')};
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
margin: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid ${cssManager.bdTheme('#c8e6c9', '#1b5e20')};
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: ${cssManager.bdTheme('#c8e6c9', '#3d4f3d')};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tag .remove {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tag .remove:hover {
|
||||
opacity: 1;
|
||||
color: ${cssManager.bdTheme('#c62828', '#ef5350')};
|
||||
}
|
||||
|
||||
/* Disabled state */
|
||||
:host([disabled]) .mainbox {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:host([disabled]) .tags {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:host([disabled]) .tag {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:host([disabled]) input {
|
||||
cursor: not-allowed;
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description}></dees-label>
|
||||
<div class="mainbox">
|
||||
<div class="tags" @click=${() => {
|
||||
this.shadowRoot.querySelector('input').focus();
|
||||
}}>
|
||||
${this.value.length === 0
|
||||
? html`<div class="notags">No tags yet</div>`
|
||||
: this.value.map(
|
||||
(tag) => html`
|
||||
<span class="tag">
|
||||
${tag}
|
||||
<span class="remove" @click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.removeTag(tag);
|
||||
}}>×</span>
|
||||
</span>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type, press Enter to add it..."
|
||||
.value=${this.inputValue}
|
||||
@input=${(e: InputEvent) => {
|
||||
this.inputValue = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && this.inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
this.addTag(this.inputValue.trim());
|
||||
}
|
||||
}}
|
||||
.disabled=${this.disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private addTag(tag: string) {
|
||||
if (!this.value.includes(tag)) {
|
||||
this.value = [...this.value, tag];
|
||||
this.inputValue = '';
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
private removeTag(tag: string) {
|
||||
this.value = this.value.filter((t) => t !== tag);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string[] {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string[]): void {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dees-input-typelist.js';
|
||||
1139
ts_web/elements/00group-input/dees-input-wysiwyg.demo.ts
Normal file
1139
ts_web/elements/00group-input/dees-input-wysiwyg.demo.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
ts_web/elements/00group-input/dees-input-wysiwyg.ts
Normal file
3
ts_web/elements/00group-input/dees-input-wysiwyg.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Re-export the component and related helpers from the dedicated subdirectory
|
||||
export { DeesInputWysiwyg } from './dees-input-wysiwyg/dees-input-wysiwyg.js';
|
||||
export * from './dees-input-wysiwyg/index.js';
|
||||
@@ -0,0 +1,294 @@
|
||||
# Critical WYSIWYG Knowledge - DO NOT LOSE
|
||||
|
||||
This document captures all the hard-won knowledge from our WYSIWYG editor development. These patterns and solutions took significant effort to discover and MUST be preserved during refactoring.
|
||||
|
||||
## 1. Static Rendering to Prevent Focus Loss
|
||||
|
||||
### Problem
|
||||
When using Lit's reactive rendering, every state change would cause a re-render, which would:
|
||||
- Lose cursor position
|
||||
- Lose focus state
|
||||
- Interrupt typing
|
||||
- Break IME (Input Method Editor) support
|
||||
|
||||
### Solution
|
||||
We render blocks **statically** and manage updates imperatively:
|
||||
|
||||
```typescript
|
||||
// In dees-wysiwyg-block.ts
|
||||
render(): TemplateResult {
|
||||
if (!this.block) return html``;
|
||||
// Render empty container - content set in firstUpdated
|
||||
return html`<div class="wysiwyg-block-container"></div>`;
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container');
|
||||
if (container && this.block) {
|
||||
container.innerHTML = this.renderBlockContent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Pattern
|
||||
- NEVER use reactive properties that would trigger re-renders during typing
|
||||
- Use `shouldUpdate()` to prevent unnecessary renders
|
||||
- Manage content updates imperatively through event handlers
|
||||
|
||||
## 2. Shadow DOM Selection Handling
|
||||
|
||||
### Problem
|
||||
The Web Selection API doesn't work across Shadow DOM boundaries by default. This broke:
|
||||
- Text selection
|
||||
- Cursor position tracking
|
||||
- Formatting detection
|
||||
- Content splitting for Enter key
|
||||
|
||||
### Solution
|
||||
Use the `getComposedRanges` API with all relevant shadow roots:
|
||||
|
||||
```typescript
|
||||
// From paragraph.block.ts
|
||||
const wysiwygBlock = element.closest('dees-wysiwyg-block');
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = (wysiwygBlock as any)?.shadowRoot;
|
||||
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
```
|
||||
|
||||
### Critical Pattern
|
||||
- ALWAYS collect all shadow roots in the hierarchy
|
||||
- Use `WysiwygSelection` utility methods that handle shadow DOM
|
||||
- Never use raw `window.getSelection()` without shadow root context
|
||||
|
||||
## 3. Cursor Position Tracking
|
||||
|
||||
### Problem
|
||||
Cursor position would be lost during various operations, making it impossible to:
|
||||
- Split content at the right position for Enter key
|
||||
- Restore cursor after operations
|
||||
- Track position for formatting
|
||||
|
||||
### Solution
|
||||
Track cursor position through multiple events and maintain `lastKnownCursorPosition`:
|
||||
|
||||
```typescript
|
||||
// Track on every relevant event
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
|
||||
// In event handlers:
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Fallback when selection not available:
|
||||
if (!selectionInfo && this.lastKnownCursorPosition !== null) {
|
||||
// Use last known position
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Events to Track
|
||||
- `input` - After text changes
|
||||
- `keydown` - Before key press
|
||||
- `keyup` - After key press
|
||||
- `mouseup` - After mouse selection
|
||||
- `click` - With setTimeout(0) for browser to set cursor
|
||||
|
||||
## 4. Content Splitting for Enter Key
|
||||
|
||||
### Problem
|
||||
Splitting content at cursor position while preserving HTML formatting was complex due to:
|
||||
- Need to preserve formatting tags
|
||||
- Shadow DOM complications
|
||||
- Cursor position accuracy
|
||||
|
||||
### Solution
|
||||
Use Range API to split content while preserving HTML:
|
||||
|
||||
```typescript
|
||||
getSplitContent(): { before: string; after: string } | null {
|
||||
// Create ranges for before and after cursor
|
||||
const beforeRange = document.createRange();
|
||||
const afterRange = document.createRange();
|
||||
|
||||
beforeRange.setStart(element, 0);
|
||||
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
afterRange.setEnd(element, element.childNodes.length);
|
||||
|
||||
// Extract HTML content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML strings
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(beforeFragment);
|
||||
const beforeHtml = tempDiv.innerHTML;
|
||||
|
||||
tempDiv.innerHTML = '';
|
||||
tempDiv.appendChild(afterFragment);
|
||||
const afterHtml = tempDiv.innerHTML;
|
||||
|
||||
return { before: beforeHtml, after: afterHtml };
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Focus Management
|
||||
|
||||
### Problem
|
||||
Focus would be lost or not properly set due to:
|
||||
- Timing issues with DOM updates
|
||||
- Shadow DOM complications
|
||||
- Browser inconsistencies
|
||||
|
||||
### Solution
|
||||
Use defensive focus management with fallbacks:
|
||||
|
||||
```typescript
|
||||
focus(element: HTMLElement): void {
|
||||
const block = element.querySelector('.block');
|
||||
if (!block) return;
|
||||
|
||||
// Ensure focusable
|
||||
if (!block.hasAttribute('contenteditable')) {
|
||||
block.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
block.focus();
|
||||
|
||||
// Fallback with microtask if focus failed
|
||||
if (document.activeElement !== block && element.shadowRoot?.activeElement !== block) {
|
||||
Promise.resolve().then(() => {
|
||||
block.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Selection Event Handling for Formatting
|
||||
|
||||
### Problem
|
||||
Need to show formatting menu when text is selected, but selection events don't bubble across Shadow DOM.
|
||||
|
||||
### Solution
|
||||
Dispatch custom events with selection information:
|
||||
|
||||
```typescript
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionchange', () => {
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (selectedText !== this.lastSelectedText) {
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: selectedText,
|
||||
blockId: block.id,
|
||||
range: range,
|
||||
rect: rect,
|
||||
hasSelection: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Custom event bubbles through Shadow DOM
|
||||
const event = new CustomEvent('block-text-selected', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
});
|
||||
```
|
||||
|
||||
## 7. IME (Input Method Editor) Support
|
||||
|
||||
### Problem
|
||||
Composition events for non-Latin input methods would break without proper handling.
|
||||
|
||||
### Solution
|
||||
Track composition state and handle events:
|
||||
|
||||
```typescript
|
||||
// In dees-input-wysiwyg.ts
|
||||
public isComposing: boolean = false;
|
||||
|
||||
// In block handlers
|
||||
element.addEventListener('compositionstart', () => {
|
||||
handlers.onCompositionStart(); // Sets isComposing = true
|
||||
});
|
||||
|
||||
element.addEventListener('compositionend', () => {
|
||||
handlers.onCompositionEnd(); // Sets isComposing = false
|
||||
});
|
||||
|
||||
// Don't process certain operations during composition
|
||||
if (this.isComposing) return;
|
||||
```
|
||||
|
||||
## 8. Programmatic Rendering
|
||||
|
||||
### Problem
|
||||
Lit's declarative rendering would cause focus loss and performance issues with many blocks.
|
||||
|
||||
### Solution
|
||||
Render blocks programmatically:
|
||||
|
||||
```typescript
|
||||
public renderBlocksProgrammatically() {
|
||||
if (!this.editorContentRef) return;
|
||||
|
||||
// Clear existing blocks
|
||||
this.editorContentRef.innerHTML = '';
|
||||
|
||||
// Create and append block elements
|
||||
this.blocks.forEach(block => {
|
||||
const blockWrapper = this.createBlockElement(block);
|
||||
this.editorContentRef.appendChild(blockWrapper);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Block Handler Architecture Requirements
|
||||
|
||||
When creating new block handlers, they MUST:
|
||||
|
||||
1. **Implement all cursor/selection methods** even if not applicable
|
||||
2. **Use Shadow DOM-aware selection utilities**
|
||||
3. **Track cursor position through events**
|
||||
4. **Handle focus with fallbacks**
|
||||
5. **Preserve HTML content when getting/setting**
|
||||
6. **Dispatch selection events for formatting**
|
||||
7. **Support IME composition events**
|
||||
8. **Clean up event listeners on disconnect**
|
||||
|
||||
## 10. Testing Considerations
|
||||
|
||||
### webhelpers.fixture() Issue
|
||||
The test helper `webhelpers.fixture()` triggers property changes during initialization that can cause null reference errors. Always:
|
||||
|
||||
1. Check for null/undefined before accessing nested properties
|
||||
2. Set required properties in specific order when testing
|
||||
3. Consider manual element creation for complex test scenarios
|
||||
|
||||
## Summary
|
||||
|
||||
These patterns represent hours of debugging and problem-solving. When refactoring:
|
||||
|
||||
1. **NEVER** remove static rendering approach
|
||||
2. **ALWAYS** use Shadow DOM-aware selection utilities
|
||||
3. **MAINTAIN** cursor position tracking through all events
|
||||
4. **PRESERVE** the complex content splitting logic
|
||||
5. **KEEP** all focus management fallbacks
|
||||
6. **ENSURE** selection events bubble through Shadow DOM
|
||||
7. **SUPPORT** IME composition events
|
||||
8. **TEST** thoroughly with actual typing, not just unit tests
|
||||
|
||||
Any changes that break these patterns will result in a degraded user experience that took significant effort to achieve.
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IBlock } from '../wysiwyg.types.js';
|
||||
import type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
|
||||
|
||||
// Re-export types from the interfaces
|
||||
export type { IBlockEventHandlers } from '../wysiwyg.interfaces.js';
|
||||
|
||||
export interface IBlockContext {
|
||||
shadowRoot: ShadowRoot;
|
||||
component: any; // Reference to the wysiwyg-block component
|
||||
}
|
||||
|
||||
export interface IBlockHandler {
|
||||
type: string;
|
||||
render(block: IBlock, isSelected: boolean): string;
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void;
|
||||
getStyles(): string;
|
||||
getPlaceholder?(): string;
|
||||
|
||||
// Optional methods for editable blocks - now with context
|
||||
getContent?(element: HTMLElement, context?: IBlockContext): string;
|
||||
setContent?(element: HTMLElement, content: string, context?: IBlockContext): void;
|
||||
getCursorPosition?(element: HTMLElement, context?: IBlockContext): number | null;
|
||||
setCursorToStart?(element: HTMLElement, context?: IBlockContext): void;
|
||||
setCursorToEnd?(element: HTMLElement, context?: IBlockContext): void;
|
||||
focus?(element: HTMLElement, context?: IBlockContext): void;
|
||||
focusWithCursor?(element: HTMLElement, position: 'start' | 'end' | number, context?: IBlockContext): void;
|
||||
getSplitContent?(element: HTMLElement, context?: IBlockContext): { before: string; after: string } | null;
|
||||
}
|
||||
|
||||
|
||||
export abstract class BaseBlockHandler implements IBlockHandler {
|
||||
abstract type: string;
|
||||
abstract render(block: IBlock, isSelected: boolean): string;
|
||||
|
||||
// Default implementation for common setup
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
// Common setup logic
|
||||
}
|
||||
|
||||
// Common styles can be defined here
|
||||
getStyles(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IBlockHandler } from './block.base.js';
|
||||
|
||||
export class BlockRegistry {
|
||||
private static handlers = new Map<string, IBlockHandler>();
|
||||
|
||||
static register(type: string, handler: IBlockHandler): void {
|
||||
this.handlers.set(type, handler);
|
||||
}
|
||||
|
||||
static getHandler(type: string): IBlockHandler | undefined {
|
||||
return this.handlers.get(type);
|
||||
}
|
||||
|
||||
static getAllTypes(): string[] {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Common styles shared across all block types
|
||||
*/
|
||||
|
||||
export const commonBlockStyles = `
|
||||
/* Common block spacing and layout */
|
||||
/* TODO: Extract common spacing from existing blocks */
|
||||
|
||||
/* Common focus states */
|
||||
/* TODO: Extract common focus styles */
|
||||
|
||||
/* Common selected states */
|
||||
/* TODO: Extract common selection styles */
|
||||
|
||||
/* Common hover states */
|
||||
/* TODO: Extract common hover styles */
|
||||
|
||||
/* Common transition effects */
|
||||
/* TODO: Extract common transitions */
|
||||
|
||||
/* Common placeholder styles */
|
||||
/* TODO: Extract common placeholder styles */
|
||||
|
||||
/* Common error states */
|
||||
/* TODO: Extract common error styles */
|
||||
|
||||
/* Common loading states */
|
||||
/* TODO: Extract common loading styles */
|
||||
`;
|
||||
|
||||
/**
|
||||
* Helper function to generate consistent block classes
|
||||
*/
|
||||
export const getBlockClasses = (
|
||||
type: string,
|
||||
isSelected: boolean,
|
||||
additionalClasses: string[] = []
|
||||
): string => {
|
||||
const classes = ['block', type];
|
||||
if (isSelected) {
|
||||
classes.push('selected');
|
||||
}
|
||||
classes.push(...additionalClasses);
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to generate consistent data attributes
|
||||
*/
|
||||
export const getBlockDataAttributes = (
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
additionalAttributes: Record<string, string> = {}
|
||||
): string => {
|
||||
const attributes = {
|
||||
'data-block-id': blockId,
|
||||
'data-block-type': blockType,
|
||||
...additionalAttributes
|
||||
};
|
||||
|
||||
return Object.entries(attributes)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(' ');
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export class DividerBlockHandler extends BaseBlockHandler {
|
||||
type = 'divider';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const selectedClass = isSelected ? ' selected' : '';
|
||||
return `
|
||||
<div class="block divider${selectedClass}" data-block-id="${block.id}" data-block-type="${block.type}" tabindex="0">
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const dividerBlock = element.querySelector('.block.divider') as HTMLDivElement;
|
||||
if (!dividerBlock) return;
|
||||
|
||||
// Handle click to select
|
||||
dividerBlock.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Focus will trigger the selection
|
||||
dividerBlock.focus();
|
||||
// Ensure focus handler is called immediately
|
||||
handlers.onFocus?.();
|
||||
});
|
||||
|
||||
// Handle focus/blur
|
||||
dividerBlock.addEventListener('focus', () => {
|
||||
handlers.onFocus?.();
|
||||
});
|
||||
|
||||
dividerBlock.addEventListener('blur', () => {
|
||||
handlers.onBlur?.();
|
||||
});
|
||||
|
||||
// Handle keyboard events
|
||||
dividerBlock.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
// Let the keyboard handler in the parent component handle the deletion
|
||||
handlers.onKeyDown?.(e);
|
||||
} else {
|
||||
// Handle navigation keys
|
||||
handlers.onKeyDown?.(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
.block.divider {
|
||||
padding: 8px 0;
|
||||
margin: 16px 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.block.divider:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.block.divider.selected {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')};
|
||||
box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')};
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* HTMLBlockHandler - Handles raw HTML content with preview/edit toggle
|
||||
*
|
||||
* Features:
|
||||
* - Live HTML preview (sandboxed)
|
||||
* - Edit/preview mode toggle
|
||||
* - Syntax highlighting in edit mode
|
||||
* - HTML validation hints
|
||||
* - Auto-save on mode switch
|
||||
*/
|
||||
export class HtmlBlockHandler extends BaseBlockHandler {
|
||||
type = 'html';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const isEditMode = block.metadata?.isEditMode ?? true;
|
||||
const content = block.content || '';
|
||||
|
||||
return `
|
||||
<div class="html-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-edit-mode="${isEditMode}">
|
||||
<div class="html-header">
|
||||
<div class="html-icon"></></div>
|
||||
<div class="html-title">HTML</div>
|
||||
<button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
|
||||
${isEditMode ? '👁️' : '✏️'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="html-content">
|
||||
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEditor(content: string): string {
|
||||
return `
|
||||
<textarea class="html-editor"
|
||||
placeholder="Enter HTML content..."
|
||||
spellcheck="false">${this.escapeHtml(content)}</textarea>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview(content: string): string {
|
||||
return `
|
||||
<div class="html-preview">
|
||||
${content || '<div class="preview-empty">No content to preview</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.html-block-container') as HTMLElement;
|
||||
const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement;
|
||||
|
||||
if (!container || !toggleBtn) {
|
||||
console.error('HtmlBlockHandler: Could not find required elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize metadata
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
|
||||
|
||||
// Toggle mode button
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Save current content if in edit mode
|
||||
if (block.metadata.isEditMode) {
|
||||
const editor = container.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
block.content = editor.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
block.metadata.isEditMode = !block.metadata.isEditMode;
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
});
|
||||
|
||||
// Setup based on mode
|
||||
if (block.metadata.isEditMode) {
|
||||
this.setupEditor(element, block, handlers);
|
||||
} else {
|
||||
this.setupPreview(element, block, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return;
|
||||
|
||||
// Focus handling
|
||||
editor.addEventListener('focus', () => handlers.onFocus());
|
||||
editor.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Content changes
|
||||
editor.addEventListener('input', () => {
|
||||
block.content = editor.value;
|
||||
this.validateHtml(editor.value);
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
// Tab handling for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const value = editor.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Unindent
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lastNewline = beforeCursor.lastIndexOf('\n');
|
||||
const lineStart = lastNewline + 1;
|
||||
const lineContent = value.substring(lineStart, start);
|
||||
|
||||
if (lineContent.startsWith(' ')) {
|
||||
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
|
||||
editor.selectionStart = editor.selectionEnd = start - 2;
|
||||
}
|
||||
} else {
|
||||
// Indent
|
||||
editor.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + 2;
|
||||
}
|
||||
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-close tags (Ctrl/Cmd + /)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
e.preventDefault();
|
||||
this.autoCloseTag(editor);
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass other key events to handlers
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Auto-resize
|
||||
this.autoResize(editor);
|
||||
editor.addEventListener('input', () => this.autoResize(editor));
|
||||
}
|
||||
|
||||
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.html-block-container') as HTMLElement;
|
||||
const preview = element.querySelector('.html-preview') as HTMLElement;
|
||||
|
||||
if (!container || !preview) return;
|
||||
|
||||
// Make preview focusable
|
||||
preview.setAttribute('tabindex', '0');
|
||||
|
||||
// Focus handling
|
||||
preview.addEventListener('focus', () => handlers.onFocus());
|
||||
preview.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
preview.addEventListener('keydown', (e) => {
|
||||
// Switch to edit mode on Enter
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
block.metadata.isEditMode = true;
|
||||
handlers.onRequestUpdate?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Sandbox styles and scripts in preview
|
||||
this.sandboxContent(preview);
|
||||
}
|
||||
|
||||
private autoCloseTag(editor: HTMLTextAreaElement): void {
|
||||
const cursorPos = editor.selectionStart;
|
||||
const text = editor.value;
|
||||
|
||||
// Find the opening tag
|
||||
let tagStart = cursorPos;
|
||||
while (tagStart > 0 && text[tagStart - 1] !== '<') {
|
||||
tagStart--;
|
||||
}
|
||||
|
||||
if (tagStart > 0) {
|
||||
const tagContent = text.substring(tagStart, cursorPos);
|
||||
const tagMatch = tagContent.match(/^(\w+)/);
|
||||
|
||||
if (tagMatch) {
|
||||
const tagName = tagMatch[1];
|
||||
const closingTag = `</${tagName}>`;
|
||||
|
||||
// Insert closing tag
|
||||
editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos);
|
||||
editor.selectionStart = editor.selectionEnd = cursorPos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private autoResize(editor: HTMLTextAreaElement): void {
|
||||
editor.style.height = 'auto';
|
||||
editor.style.height = editor.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
private validateHtml(html: string): boolean {
|
||||
// Basic HTML validation
|
||||
const openTags: string[] = [];
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g;
|
||||
let match;
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const isClosing = match[0].startsWith('</');
|
||||
const tagName = match[1].toLowerCase();
|
||||
|
||||
if (isClosing) {
|
||||
if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) {
|
||||
console.warn(`Mismatched closing tag: ${tagName}`);
|
||||
return false;
|
||||
}
|
||||
openTags.pop();
|
||||
} else if (!match[0].endsWith('/>')) {
|
||||
// Not a self-closing tag
|
||||
openTags.push(tagName);
|
||||
}
|
||||
}
|
||||
|
||||
if (openTags.length > 0) {
|
||||
console.warn(`Unclosed tags: ${openTags.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sandboxContent(preview: HTMLElement): void {
|
||||
// Remove any script tags
|
||||
const scripts = preview.querySelectorAll('script');
|
||||
scripts.forEach(script => script.remove());
|
||||
|
||||
// Remove event handlers
|
||||
const allElements = preview.querySelectorAll('*');
|
||||
allElements.forEach(el => {
|
||||
// Remove all on* attributes
|
||||
Array.from(el.attributes).forEach(attr => {
|
||||
if (attr.name.startsWith('on')) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent forms from submitting
|
||||
const forms = preview.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
return editor.value;
|
||||
}
|
||||
|
||||
// If in preview mode, return the stored content
|
||||
const container = element.querySelector('.html-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
// In real implementation, would need access to block data
|
||||
return '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.value = content;
|
||||
this.autoResize(editor);
|
||||
}
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
return editor ? editor.selectionStart : null;
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.selectionStart = editor.selectionEnd = 0;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
const length = editor.value.length;
|
||||
editor.selectionStart = editor.selectionEnd = length;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
} else {
|
||||
const preview = element.querySelector('.html-preview') as HTMLElement;
|
||||
preview?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element);
|
||||
} else if (typeof position === 'number') {
|
||||
editor.selectionStart = editor.selectionEnd = position;
|
||||
editor.focus();
|
||||
}
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
const editor = element.querySelector('.html-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return null;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
return {
|
||||
before: editor.value.substring(0, cursorPos),
|
||||
after: editor.value.substring(cursorPos)
|
||||
};
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* HTML Block Container */
|
||||
.html-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
}
|
||||
|
||||
.html-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.html-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.html-icon {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.html-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.html-toggle-mode {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.html-toggle-mode:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.html-content {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.html-editor {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.html-editor::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.html-preview {
|
||||
padding: 12px;
|
||||
min-height: 96px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Sandboxed HTML preview styles */
|
||||
.html-preview * {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.html-preview img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.html-preview a {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.html-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.html-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.html-preview th,
|
||||
.html-preview td {
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.html-preview th {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.html-preview pre {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.html-preview code {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.html-preview pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* MarkdownBlockHandler - Handles markdown content with preview/edit toggle
|
||||
*
|
||||
* Features:
|
||||
* - Live markdown preview
|
||||
* - Edit/preview mode toggle
|
||||
* - Syntax highlighting in edit mode
|
||||
* - Common markdown shortcuts
|
||||
* - Auto-save on mode switch
|
||||
*/
|
||||
export class MarkdownBlockHandler extends BaseBlockHandler {
|
||||
type = 'markdown';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const isEditMode = block.metadata?.isEditMode ?? true;
|
||||
const content = block.content || '';
|
||||
|
||||
return `
|
||||
<div class="markdown-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-edit-mode="${isEditMode}">
|
||||
<div class="markdown-header">
|
||||
<div class="markdown-icon">M↓</div>
|
||||
<div class="markdown-title">Markdown</div>
|
||||
<button class="markdown-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
|
||||
${isEditMode ? '👁️' : '✏️'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="markdown-content">
|
||||
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEditor(content: string): string {
|
||||
return `
|
||||
<textarea class="markdown-editor"
|
||||
placeholder="Enter markdown content..."
|
||||
spellcheck="false">${this.escapeHtml(content)}</textarea>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPreview(content: string): string {
|
||||
const html = this.parseMarkdown(content);
|
||||
return `
|
||||
<div class="markdown-preview">
|
||||
${html || '<div class="preview-empty">No content to preview</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.markdown-block-container') as HTMLElement;
|
||||
const toggleBtn = element.querySelector('.markdown-toggle-mode') as HTMLButtonElement;
|
||||
|
||||
if (!container || !toggleBtn) {
|
||||
console.error('MarkdownBlockHandler: Could not find required elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize metadata
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
|
||||
|
||||
// Toggle mode button
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Save current content if in edit mode
|
||||
if (block.metadata.isEditMode) {
|
||||
const editor = container.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
block.content = editor.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
block.metadata.isEditMode = !block.metadata.isEditMode;
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
});
|
||||
|
||||
// Setup based on mode
|
||||
if (block.metadata.isEditMode) {
|
||||
this.setupEditor(element, block, handlers);
|
||||
} else {
|
||||
this.setupPreview(element, block, handlers);
|
||||
}
|
||||
}
|
||||
|
||||
private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return;
|
||||
|
||||
// Focus handling
|
||||
editor.addEventListener('focus', () => handlers.onFocus());
|
||||
editor.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Content changes
|
||||
editor.addEventListener('input', () => {
|
||||
block.content = editor.value;
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
// Tab handling for indentation
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const value = editor.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Unindent
|
||||
const beforeCursor = value.substring(0, start);
|
||||
const lastNewline = beforeCursor.lastIndexOf('\n');
|
||||
const lineStart = lastNewline + 1;
|
||||
const lineContent = value.substring(lineStart, start);
|
||||
|
||||
if (lineContent.startsWith(' ')) {
|
||||
editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
|
||||
editor.selectionStart = editor.selectionEnd = start - 2;
|
||||
}
|
||||
} else {
|
||||
// Indent
|
||||
editor.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
editor.selectionStart = editor.selectionEnd = start + 2;
|
||||
}
|
||||
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bold shortcut (Ctrl/Cmd + B)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
this.wrapSelection(editor, '**', '**');
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Italic shortcut (Ctrl/Cmd + I)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
||||
e.preventDefault();
|
||||
this.wrapSelection(editor, '_', '_');
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Link shortcut (Ctrl/Cmd + K)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.insertLink(editor);
|
||||
block.content = editor.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass other key events to handlers
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Auto-resize
|
||||
this.autoResize(editor);
|
||||
editor.addEventListener('input', () => this.autoResize(editor));
|
||||
}
|
||||
|
||||
private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.markdown-block-container') as HTMLElement;
|
||||
const preview = element.querySelector('.markdown-preview') as HTMLElement;
|
||||
|
||||
if (!container || !preview) return;
|
||||
|
||||
// Make preview focusable
|
||||
preview.setAttribute('tabindex', '0');
|
||||
|
||||
// Focus handling
|
||||
preview.addEventListener('focus', () => handlers.onFocus());
|
||||
preview.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
preview.addEventListener('keydown', (e) => {
|
||||
// Switch to edit mode on Enter
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
block.metadata.isEditMode = true;
|
||||
handlers.onRequestUpdate?.();
|
||||
return;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
}
|
||||
|
||||
private wrapSelection(editor: HTMLTextAreaElement, before: string, after: string): void {
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const selectedText = editor.value.substring(start, end);
|
||||
const replacement = before + (selectedText || 'text') + after;
|
||||
|
||||
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
|
||||
|
||||
if (selectedText) {
|
||||
editor.selectionStart = start;
|
||||
editor.selectionEnd = start + replacement.length;
|
||||
} else {
|
||||
editor.selectionStart = start + before.length;
|
||||
editor.selectionEnd = start + before.length + 4; // 'text'.length
|
||||
}
|
||||
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
private insertLink(editor: HTMLTextAreaElement): void {
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const selectedText = editor.value.substring(start, end);
|
||||
const linkText = selectedText || 'link text';
|
||||
const replacement = `[${linkText}](url)`;
|
||||
|
||||
editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
|
||||
|
||||
// Select the URL part
|
||||
editor.selectionStart = start + linkText.length + 3; // '[linktext]('.length
|
||||
editor.selectionEnd = start + linkText.length + 6; // '[linktext](url'.length
|
||||
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
private autoResize(editor: HTMLTextAreaElement): void {
|
||||
editor.style.height = 'auto';
|
||||
editor.style.height = editor.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
private parseMarkdown(markdown: string): string {
|
||||
// Basic markdown parsing - in production, use a proper markdown parser
|
||||
let html = this.escapeHtml(markdown);
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Lists
|
||||
html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
|
||||
|
||||
// Wrap consecutive list items
|
||||
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => {
|
||||
return '<ul>' + match + '</ul>';
|
||||
});
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p><\/p>/g, '');
|
||||
html = html.replace(/<p>(<h[1-3]>)/g, '$1');
|
||||
html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<ul>)/g, '$1');
|
||||
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p>(<pre>)/g, '$1');
|
||||
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
return editor.value;
|
||||
}
|
||||
|
||||
// If in preview mode, return the stored content
|
||||
const container = element.querySelector('.markdown-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
// In real implementation, would need access to block data
|
||||
return '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.value = content;
|
||||
this.autoResize(editor);
|
||||
}
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
return editor ? editor.selectionStart : null;
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.selectionStart = editor.selectionEnd = 0;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
const length = editor.value.length;
|
||||
editor.selectionStart = editor.selectionEnd = length;
|
||||
editor.focus();
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
editor.focus();
|
||||
} else {
|
||||
const preview = element.querySelector('.markdown-preview') as HTMLElement;
|
||||
preview?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (editor) {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element);
|
||||
} else if (typeof position === 'number') {
|
||||
editor.selectionStart = editor.selectionEnd = position;
|
||||
editor.focus();
|
||||
}
|
||||
} else {
|
||||
this.focus(element);
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
|
||||
if (!editor) return null;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
return {
|
||||
before: editor.value.substring(0, cursorPos),
|
||||
after: editor.value.substring(cursorPos)
|
||||
};
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Markdown Block Container */
|
||||
.markdown-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
}
|
||||
|
||||
.markdown-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.markdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.markdown-icon {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.markdown-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.markdown-toggle-mode {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.markdown-toggle-mode:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.markdown-content {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.markdown-editor {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-editor::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.markdown-preview {
|
||||
padding: 12px;
|
||||
min-height: 96px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Markdown preview styles */
|
||||
.markdown-preview h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px 0;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 14px 0 6px 0;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 12px 0 4px 0;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
|
||||
.markdown-preview p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-preview ul,
|
||||
.markdown-preview ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.markdown-preview li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-preview pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-preview strong {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
|
||||
.markdown-preview em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-preview a {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-preview blockquote {
|
||||
border-left: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Main exports for the blocks module
|
||||
*/
|
||||
|
||||
// Core interfaces and base classes
|
||||
export {
|
||||
type IBlockHandler,
|
||||
type IBlockEventHandlers,
|
||||
BaseBlockHandler
|
||||
} from './block.base.js';
|
||||
|
||||
// Block registry for registration and retrieval
|
||||
export { BlockRegistry } from './block.registry.js';
|
||||
|
||||
// Common styles and helpers
|
||||
export {
|
||||
commonBlockStyles,
|
||||
getBlockClasses,
|
||||
getBlockDataAttributes
|
||||
} from './block.styles.js';
|
||||
|
||||
// Text block handlers
|
||||
export { ParagraphBlockHandler } from './text/paragraph.block.js';
|
||||
export { HeadingBlockHandler } from './text/heading.block.js';
|
||||
export { QuoteBlockHandler } from './text/quote.block.js';
|
||||
export { CodeBlockHandler } from './text/code.block.js';
|
||||
export { ListBlockHandler } from './text/list.block.js';
|
||||
|
||||
// Media block handlers
|
||||
export { ImageBlockHandler } from './media/image.block.js';
|
||||
export { YouTubeBlockHandler } from './media/youtube.block.js';
|
||||
export { AttachmentBlockHandler } from './media/attachment.block.js';
|
||||
|
||||
// Content block handlers
|
||||
export { DividerBlockHandler } from './content/divider.block.js';
|
||||
export { MarkdownBlockHandler } from './content/markdown.block.js';
|
||||
export { HtmlBlockHandler } from './content/html.block.js';
|
||||
|
||||
// Utilities
|
||||
// TODO: Export when implemented
|
||||
// export * from './utils/file.utils.js';
|
||||
// export * from './utils/media.utils.js';
|
||||
// export * from './utils/markdown.utils.js';
|
||||
@@ -0,0 +1,477 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* AttachmentBlockHandler - Handles file attachments
|
||||
*
|
||||
* Features:
|
||||
* - Multiple file upload support
|
||||
* - Click to upload or drag and drop
|
||||
* - File type icons
|
||||
* - Remove individual files
|
||||
* - Base64 encoding (TODO: server upload in production)
|
||||
*/
|
||||
export class AttachmentBlockHandler extends BaseBlockHandler {
|
||||
type = 'attachment';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const files = block.metadata?.files || [];
|
||||
|
||||
return `
|
||||
<div class="attachment-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
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 ? this.renderFiles(files) : this.renderPlaceholder()}
|
||||
</div>
|
||||
<input type="file"
|
||||
class="attachment-file-input"
|
||||
multiple
|
||||
style="display: none;" />
|
||||
${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): string {
|
||||
return `
|
||||
<div class="attachment-placeholder">
|
||||
<div class="placeholder-text">Click to add files</div>
|
||||
<div class="placeholder-hint">or drag and drop</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFiles(files: any[]): string {
|
||||
return files.map((file: any) => `
|
||||
<div class="attachment-item" data-file-id="${file.id}">
|
||||
<div class="file-icon">${this.getFileIcon(file.type)}</div>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${this.escapeHtml(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('');
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.attachment-block-container') as HTMLElement;
|
||||
const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement;
|
||||
|
||||
if (!container || !fileInput) {
|
||||
console.error('AttachmentBlockHandler: Could not find required elements');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize files array if needed
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (!block.metadata.files) block.metadata.files = [];
|
||||
|
||||
// Click to upload on placeholder
|
||||
const placeholder = container.querySelector('.attachment-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Add more files button
|
||||
const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement;
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (files && files.length > 0) {
|
||||
await this.handleFileAttachments(files, block, handlers);
|
||||
input.value = ''; // Clear input for next selection
|
||||
}
|
||||
});
|
||||
|
||||
// Remove file buttons
|
||||
container.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('remove-file')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fileId = target.getAttribute('data-file-id');
|
||||
if (fileId) {
|
||||
this.removeFile(fileId, block, handlers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.add('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
await this.handleFileAttachments(files, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus/blur
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// Only remove all files if container is focused, not when removing individual files
|
||||
if (document.activeElement === container && block.metadata?.files?.length > 0) {
|
||||
e.preventDefault();
|
||||
block.metadata.files = [];
|
||||
handlers.onRequestUpdate?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFileAttachments(
|
||||
files: FileList,
|
||||
block: IBlock,
|
||||
handlers: IBlockEventHandlers
|
||||
): Promise<void> {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
if (!block.metadata.files) block.metadata.files = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const dataUrl = await this.fileToDataUrl(file);
|
||||
const fileData = {
|
||||
id: this.generateId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
data: dataUrl
|
||||
};
|
||||
|
||||
block.metadata.files.push(fileData);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach file:', file.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update block content with file count
|
||||
block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`;
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
if (!block.metadata?.files) return;
|
||||
|
||||
block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId);
|
||||
|
||||
// Update content
|
||||
block.content = block.metadata.files.length > 0
|
||||
? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`
|
||||
: '';
|
||||
|
||||
// Request UI update
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
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('rar') || mimeType.includes('tar')) return '🗄️';
|
||||
if (mimeType.includes('sheet')) return '📊';
|
||||
if (mimeType.includes('document') || mimeType.includes('msword')) return '📝';
|
||||
if (mimeType.includes('presentation')) return '📋';
|
||||
if (mimeType.includes('text')) return '📃';
|
||||
return '📁';
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the description of attached files
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the description of attached files
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.attachment-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// Simplified version - in real implementation would need access to block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'attachment',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // Attachment blocks don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.attachment-block-container') as HTMLElement;
|
||||
container?.focus();
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // Attachment blocks can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Attachment Block Container */
|
||||
.attachment-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
}
|
||||
|
||||
.attachment-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.attachment-block-container.drag-over {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.attachment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.attachment-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
/* File List */
|
||||
.attachment-list {
|
||||
padding: 8px;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.attachment-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-placeholder:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* File Items */
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.attachment-item:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.remove-file {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove-file:hover {
|
||||
background: ${cssManager.bdTheme('#fee2e2', '#991b1b')};
|
||||
border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')};
|
||||
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
|
||||
}
|
||||
|
||||
/* Add More Files Button */
|
||||
.add-more-files {
|
||||
margin: 8px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.add-more-files:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
|
||||
/* Hidden file input */
|
||||
.attachment-file-input {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* ImageBlockHandler - Handles image upload, display, and interactions
|
||||
*
|
||||
* Features:
|
||||
* - Click to upload
|
||||
* - Drag and drop support
|
||||
* - Base64 encoding (TODO: server upload in production)
|
||||
* - Loading states
|
||||
* - Alt text from filename
|
||||
*/
|
||||
export class ImageBlockHandler extends BaseBlockHandler {
|
||||
type = 'image';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const imageUrl = block.metadata?.url;
|
||||
const altText = block.content || 'Image';
|
||||
const isLoading = block.metadata?.loading;
|
||||
|
||||
return `
|
||||
<div class="image-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-has-image="${!!imageUrl}"
|
||||
tabindex="0">
|
||||
${isLoading ? this.renderLoading() :
|
||||
imageUrl ? this.renderImage(imageUrl, altText) :
|
||||
this.renderPlaceholder()}
|
||||
<input type="file"
|
||||
class="image-file-input"
|
||||
accept="image/*"
|
||||
style="display: none;" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): string {
|
||||
return `
|
||||
<div class="image-upload-placeholder" style="cursor: pointer;">
|
||||
<div class="upload-icon" style="pointer-events: none;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
|
||||
<div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderImage(url: string, altText: string): string {
|
||||
return `
|
||||
<div class="image-container">
|
||||
<img src="${url}" alt="${this.escapeHtml(altText)}" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLoading(): string {
|
||||
return `
|
||||
<div class="image-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Uploading image...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.image-block-container') as HTMLElement;
|
||||
const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
|
||||
|
||||
if (!container) {
|
||||
console.error('ImageBlockHandler: Could not find container');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileInput) {
|
||||
console.error('ImageBlockHandler: Could not find file input');
|
||||
return;
|
||||
}
|
||||
|
||||
// Click to upload (only on placeholder)
|
||||
const placeholder = container.querySelector('.image-upload-placeholder');
|
||||
if (placeholder) {
|
||||
placeholder.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Container click for focus
|
||||
container.addEventListener('click', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// File input change
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
console.log('ImageBlockHandler: File selected:', file.name);
|
||||
await this.handleFileUpload(file, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
container.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!block.metadata?.url) {
|
||||
container.classList.add('drag-over');
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
container.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
container.classList.remove('drag-over');
|
||||
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file && file.type.startsWith('image/') && !block.metadata?.url) {
|
||||
await this.handleFileUpload(file, block, handlers);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus/blur
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Keyboard navigation
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (block.metadata?.url) {
|
||||
// Clear the image
|
||||
block.metadata.url = undefined;
|
||||
block.metadata.loading = false;
|
||||
block.content = '';
|
||||
handlers.onInput(new InputEvent('input'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFileUpload(
|
||||
file: File,
|
||||
block: IBlock,
|
||||
handlers: IBlockEventHandlers
|
||||
): Promise<void> {
|
||||
console.log('ImageBlockHandler: Starting file upload', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
blockId: block.id
|
||||
});
|
||||
|
||||
// Validate file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Invalid file type:', file.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size (10MB limit)
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
console.error('File too large. Maximum size is 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.loading = true;
|
||||
block.metadata.fileName = file.name;
|
||||
block.metadata.fileSize = file.size;
|
||||
block.metadata.mimeType = file.type;
|
||||
|
||||
console.log('ImageBlockHandler: Set loading state, requesting update');
|
||||
// Request immediate UI update for loading state
|
||||
handlers.onRequestUpdate?.();
|
||||
|
||||
try {
|
||||
// Convert to base64
|
||||
const dataUrl = await this.fileToDataUrl(file);
|
||||
|
||||
// Update block
|
||||
block.metadata.url = dataUrl;
|
||||
block.metadata.loading = false;
|
||||
|
||||
// Set default alt text from filename
|
||||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||
block.content = nameWithoutExt;
|
||||
|
||||
console.log('ImageBlockHandler: Upload complete, requesting update', {
|
||||
hasUrl: !!block.metadata.url,
|
||||
urlLength: dataUrl.length,
|
||||
altText: block.content
|
||||
});
|
||||
|
||||
// Request immediate UI update to show uploaded image
|
||||
handlers.onRequestUpdate?.();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
block.metadata.loading = false;
|
||||
// Request UI update to clear loading state
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
}
|
||||
|
||||
private fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the alt text
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the alt text
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.image-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// This is a simplified version - in real implementation,
|
||||
// we'd need access to the block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'image',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // Images don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.image-block-container') as HTMLElement;
|
||||
container?.focus();
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // Images can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Image Block Container */
|
||||
.image-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-block-container.selected {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* Upload Placeholder */
|
||||
.image-upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.image-block-container:hover .image-upload-placeholder {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
}
|
||||
|
||||
.image-block-container.drag-over .image-upload-placeholder {
|
||||
border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
margin-bottom: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Image Container */
|
||||
.image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
}
|
||||
|
||||
.image-container img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.image-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
/* File input hidden */
|
||||
.image-file-input {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
|
||||
/**
|
||||
* YouTubeBlockHandler - Handles YouTube video embedding
|
||||
*
|
||||
* Features:
|
||||
* - YouTube URL parsing and validation
|
||||
* - Video ID extraction from various YouTube URL formats
|
||||
* - Embedded iframe player
|
||||
* - Clean minimalist design
|
||||
*/
|
||||
export class YouTubeBlockHandler extends BaseBlockHandler {
|
||||
type = 'youtube';
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const videoId = block.metadata?.videoId;
|
||||
const url = block.metadata?.url || '';
|
||||
|
||||
return `
|
||||
<div class="youtube-block-container${isSelected ? ' selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
data-has-video="${!!videoId}">
|
||||
${videoId ? this.renderVideo(videoId) : this.renderPlaceholder(url)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(url: string): string {
|
||||
return `
|
||||
<div class="youtube-placeholder">
|
||||
<div class="placeholder-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="placeholder-text">Enter YouTube URL</div>
|
||||
<input type="url"
|
||||
class="youtube-url-input"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value="${this.escapeHtml(url)}" />
|
||||
<button class="youtube-embed-btn">Embed Video</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderVideo(videoId: string): string {
|
||||
return `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const container = element.querySelector('.youtube-block-container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
// If video is already embedded, just handle focus/blur
|
||||
if (block.metadata?.videoId) {
|
||||
container.setAttribute('tabindex', '0');
|
||||
container.addEventListener('focus', () => handlers.onFocus());
|
||||
container.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Handle deletion
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
handlers.onKeyDown(e);
|
||||
} else {
|
||||
handlers.onKeyDown(e);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup placeholder interactions
|
||||
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
|
||||
const embedBtn = element.querySelector('.youtube-embed-btn') as HTMLButtonElement;
|
||||
|
||||
if (!urlInput || !embedBtn) return;
|
||||
|
||||
// Focus management
|
||||
urlInput.addEventListener('focus', () => handlers.onFocus());
|
||||
urlInput.addEventListener('blur', () => handlers.onBlur());
|
||||
|
||||
// Handle embed button click
|
||||
embedBtn.addEventListener('click', () => {
|
||||
this.embedVideo(urlInput.value, block, handlers);
|
||||
});
|
||||
|
||||
// Handle Enter key in input
|
||||
urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.embedVideo(urlInput.value, block, handlers);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
urlInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle paste event
|
||||
urlInput.addEventListener('paste', (e) => {
|
||||
// Allow paste to complete first
|
||||
setTimeout(() => {
|
||||
const pastedUrl = urlInput.value;
|
||||
if (this.extractYouTubeVideoId(pastedUrl)) {
|
||||
// Auto-embed if valid YouTube URL was pasted
|
||||
this.embedVideo(pastedUrl, block, handlers);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Update URL in metadata as user types
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.url = urlInput.value;
|
||||
});
|
||||
}
|
||||
|
||||
private embedVideo(url: string, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const videoId = this.extractYouTubeVideoId(url);
|
||||
|
||||
if (!videoId) {
|
||||
// Could show an error message here
|
||||
console.error('Invalid YouTube URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update block metadata
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.videoId = videoId;
|
||||
block.metadata.url = url;
|
||||
|
||||
// Set content as video title (could be fetched from API in the future)
|
||||
block.content = `YouTube Video: ${videoId}`;
|
||||
|
||||
// Request immediate UI update to show embedded video
|
||||
handlers.onRequestUpdate?.();
|
||||
}
|
||||
|
||||
private extractYouTubeVideoId(url: string): string | null {
|
||||
// Handle various YouTube URL formats
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/,
|
||||
/youtube\.com\/embed\/([^"&?\/ ]{11})/,
|
||||
/youtube\.com\/watch\?v=([^"&?\/ ]{11})/,
|
||||
/youtu\.be\/([^"&?\/ ]{11})/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
// Content is the video description/title
|
||||
const block = this.getBlockFromElement(element);
|
||||
return block?.content || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
// Content is the video description/title
|
||||
const block = this.getBlockFromElement(element);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlockFromElement(element: HTMLElement): IBlock | null {
|
||||
const container = element.querySelector('.youtube-block-container');
|
||||
const blockId = container?.getAttribute('data-block-id');
|
||||
if (!blockId) return null;
|
||||
|
||||
// Simplified version - in real implementation would need access to block data
|
||||
return {
|
||||
id: blockId,
|
||||
type: 'youtube',
|
||||
content: '',
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
return null; // YouTube blocks don't have cursor position
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const container = element.querySelector('.youtube-block-container') as HTMLElement;
|
||||
const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement;
|
||||
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
} else if (container) {
|
||||
container.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
this.focus(element);
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
return null; // YouTube blocks can't be split
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* YouTube Block Container */
|
||||
.youtube-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-block-container.selected {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
|
||||
}
|
||||
|
||||
/* YouTube Placeholder */
|
||||
.youtube-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.youtube-url-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#111827')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-url-input:focus {
|
||||
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
background: ${cssManager.bdTheme('#ffffff', '#1f2937')};
|
||||
}
|
||||
|
||||
.youtube-url-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
}
|
||||
|
||||
.youtube-embed-btn {
|
||||
padding: 6px 16px;
|
||||
background: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
color: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.youtube-embed-btn:hover {
|
||||
background: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.youtube-embed-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* YouTube Container */
|
||||
.youtube-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
background: ${cssManager.bdTheme('#000000', '#000000')};
|
||||
}
|
||||
|
||||
.youtube-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||
import hlight from 'highlight.js';
|
||||
import { cssGeistFontFamily, cssMonoFontFamily } from '../../../../00fonts.js';
|
||||
import { PROGRAMMING_LANGUAGES } from '../../wysiwyg.constants.js';
|
||||
|
||||
/**
|
||||
* CodeBlockHandler with improved architecture
|
||||
*
|
||||
* Key features:
|
||||
* 1. Simple DOM structure
|
||||
* 2. Line number handling
|
||||
* 3. Syntax highlighting only when not focused (grey text while editing)
|
||||
* 4. Clean event handling
|
||||
* 5. Copy button functionality
|
||||
*/
|
||||
export class CodeBlockHandler extends BaseBlockHandler {
|
||||
type = 'code';
|
||||
|
||||
private highlightTimer: any = null;
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const language = block.metadata?.language || 'typescript';
|
||||
const content = block.content || '';
|
||||
const lineCount = content.split('\n').length;
|
||||
|
||||
// Generate line numbers
|
||||
let lineNumbersHtml = '';
|
||||
for (let i = 1; i <= lineCount; i++) {
|
||||
lineNumbersHtml += `<div class="line-number">${i}</div>`;
|
||||
}
|
||||
|
||||
// Generate language options
|
||||
const languageOptions = PROGRAMMING_LANGUAGES.map(lang => {
|
||||
const value = lang.toLowerCase();
|
||||
return `<option value="${value}" ${value === language ? 'selected' : ''}>${lang}</option>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="code-block-container${isSelected ? ' selected' : ''}" data-language="${language}">
|
||||
<div class="code-header">
|
||||
<select class="language-selector" data-block-id="${block.id}">
|
||||
${languageOptions}
|
||||
</select>
|
||||
<button class="copy-button" title="Copy code">
|
||||
<svg class="copy-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path>
|
||||
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path>
|
||||
</svg>
|
||||
<span class="copy-text">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<div class="line-numbers">${lineNumbersHtml}</div>
|
||||
<div class="code-content">
|
||||
<pre class="code-pre"><code class="code-editor"
|
||||
contenteditable="true"
|
||||
data-block-id="${block.id}"
|
||||
data-block-type="${block.type}"
|
||||
spellcheck="false">${this.escapeHtml(content)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
const container = element.querySelector('.code-block-container') as HTMLElement;
|
||||
const copyButton = element.querySelector('.copy-button') as HTMLButtonElement;
|
||||
const languageSelector = element.querySelector('.language-selector') as HTMLSelectElement;
|
||||
|
||||
if (!editor || !container) return;
|
||||
|
||||
// Setup language selector
|
||||
if (languageSelector) {
|
||||
languageSelector.addEventListener('change', (e) => {
|
||||
const newLanguage = (e.target as HTMLSelectElement).value;
|
||||
block.metadata = { ...block.metadata, language: newLanguage };
|
||||
container.setAttribute('data-language', newLanguage);
|
||||
|
||||
// Update the syntax highlighting if content exists and not focused
|
||||
if (block.content && document.activeElement !== editor) {
|
||||
this.applyHighlighting(element, block);
|
||||
}
|
||||
|
||||
// Notify about the change
|
||||
if (handlers.onInput) {
|
||||
handlers.onInput(new InputEvent('input'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup copy button
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener('click', async () => {
|
||||
const content = editor.textContent || '';
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
|
||||
// Show feedback
|
||||
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
|
||||
const originalText = copyText.textContent;
|
||||
copyText.textContent = 'Copied!';
|
||||
copyButton.classList.add('copied');
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
copyText.textContent = originalText;
|
||||
copyButton.classList.remove('copied');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = content;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
// @ts-ignore - execCommand is deprecated but needed for fallback
|
||||
document.execCommand('copy');
|
||||
// Show feedback
|
||||
const copyText = copyButton.querySelector('.copy-text') as HTMLElement;
|
||||
const originalText = copyText.textContent;
|
||||
copyText.textContent = 'Copied!';
|
||||
copyButton.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
copyText.textContent = originalText;
|
||||
copyButton.classList.remove('copied');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track if we're currently editing
|
||||
let isEditing = false;
|
||||
|
||||
// Focus handler
|
||||
editor.addEventListener('focus', () => {
|
||||
isEditing = true;
|
||||
container.classList.add('editing');
|
||||
|
||||
// Remove all syntax highlighting when focused
|
||||
const content = editor.textContent || '';
|
||||
editor.textContent = content; // This removes all HTML formatting
|
||||
|
||||
// Restore cursor position after removing highlighting
|
||||
requestAnimationFrame(() => {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
if (editor.firstChild) {
|
||||
range.setStart(editor.firstChild, 0);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
});
|
||||
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// Blur handler
|
||||
editor.addEventListener('blur', () => {
|
||||
isEditing = false;
|
||||
container.classList.remove('editing');
|
||||
// Apply final highlighting on blur
|
||||
this.applyHighlighting(element, block);
|
||||
handlers.onBlur();
|
||||
});
|
||||
|
||||
// Input handler
|
||||
editor.addEventListener('input', (e) => {
|
||||
handlers.onInput(e as InputEvent);
|
||||
|
||||
// Update line numbers
|
||||
this.updateLineNumbers(element);
|
||||
|
||||
// Clear any pending highlight timer (no highlighting while editing)
|
||||
clearTimeout(this.highlightTimer);
|
||||
});
|
||||
|
||||
// Keydown handler
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
// Handle Tab key for code blocks
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const textNode = document.createTextNode(' ');
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.setEndAfter(textNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
handlers.onInput(new InputEvent('input'));
|
||||
this.updateLineNumbers(element);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cursor position for navigation keys
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
const cursorPos = this.getCursorPosition(element);
|
||||
const textLength = editor.textContent?.length || 0;
|
||||
|
||||
// For ArrowLeft at position 0 or ArrowRight at end, let parent handle navigation
|
||||
if ((e.key === 'ArrowLeft' && cursorPos === 0) ||
|
||||
(e.key === 'ArrowRight' && cursorPos === textLength)) {
|
||||
// Pass to parent handler for inter-block navigation
|
||||
handlers.onKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// For ArrowUp/Down, check if we're at first/last line
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
const lines = (editor.textContent || '').split('\n');
|
||||
const currentLine = this.getCurrentLineIndex(editor);
|
||||
|
||||
if ((e.key === 'ArrowUp' && currentLine === 0) ||
|
||||
(e.key === 'ArrowDown' && currentLine === lines.length - 1)) {
|
||||
// Let parent handle navigation to prev/next block
|
||||
handlers.onKeyDown(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass other keys to parent handler
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Paste handler - plain text only
|
||||
editor.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData?.getData('text/plain');
|
||||
if (text) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(text);
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.setEndAfter(textNode);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
handlers.onInput(new InputEvent('input'));
|
||||
this.updateLineNumbers(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Composition handlers
|
||||
editor.addEventListener('compositionstart', () => handlers.onCompositionStart());
|
||||
editor.addEventListener('compositionend', () => handlers.onCompositionEnd());
|
||||
|
||||
// Initial syntax highlighting if content exists and not focused
|
||||
if (block.content && document.activeElement !== editor) {
|
||||
requestAnimationFrame(() => {
|
||||
this.applyHighlighting(element, block);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateLineNumbers(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLElement;
|
||||
|
||||
if (!editor || !lineNumbersContainer) return;
|
||||
|
||||
const content = editor.textContent || '';
|
||||
const lines = content.split('\n');
|
||||
const lineCount = lines.length || 1;
|
||||
|
||||
let lineNumbersHtml = '';
|
||||
for (let i = 1; i <= lineCount; i++) {
|
||||
lineNumbersHtml += `<div class="line-number">${i}</div>`;
|
||||
}
|
||||
|
||||
lineNumbersContainer.innerHTML = lineNumbersHtml;
|
||||
}
|
||||
|
||||
private getCurrentLineIndex(editor: HTMLElement): number {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return 0;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(editor);
|
||||
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
const textBeforeCursor = preCaretRange.toString();
|
||||
const linesBeforeCursor = textBeforeCursor.split('\n');
|
||||
|
||||
return linesBeforeCursor.length - 1; // 0-indexed
|
||||
}
|
||||
|
||||
private applyHighlighting(element: HTMLElement, block: IBlock): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (!editor) return;
|
||||
|
||||
// Store cursor position
|
||||
const cursorPos = this.getCursorPosition(element);
|
||||
|
||||
// Get plain text content
|
||||
const content = editor.textContent || '';
|
||||
const language = block.metadata?.language || 'typescript';
|
||||
|
||||
// Apply highlighting
|
||||
try {
|
||||
const result = hlight.highlight(content, {
|
||||
language: language,
|
||||
ignoreIllegals: true
|
||||
});
|
||||
|
||||
// Only update if we have valid highlighted content
|
||||
if (result.value) {
|
||||
editor.innerHTML = result.value;
|
||||
|
||||
// Restore cursor position if editor is focused
|
||||
if (document.activeElement === editor && cursorPos !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
WysiwygSelection.setCursorPosition(editor, cursorPos);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If highlighting fails, keep plain text
|
||||
console.warn('Syntax highlighting failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement): string {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
return editor?.textContent || '';
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (!editor) return;
|
||||
|
||||
editor.textContent = content;
|
||||
this.updateLineNumbers(element);
|
||||
|
||||
// Apply highlighting if not focused
|
||||
if (document.activeElement !== editor) {
|
||||
const block: IBlock = {
|
||||
id: editor.dataset.blockId || '',
|
||||
type: 'code',
|
||||
content: content,
|
||||
metadata: {
|
||||
language: element.querySelector('.code-block-container')?.getAttribute('data-language') || 'typescript'
|
||||
}
|
||||
};
|
||||
this.applyHighlighting(element, block);
|
||||
}
|
||||
}
|
||||
|
||||
getCursorPosition(element: HTMLElement): number | null {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (!editor) return null;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!editor.contains(range.startContainer)) return null;
|
||||
|
||||
const preCaretRange = document.createRange();
|
||||
preCaretRange.selectNodeContents(editor);
|
||||
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
return preCaretRange.toString().length;
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (editor) {
|
||||
WysiwygSelection.setCursorPosition(editor, 0);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (editor) {
|
||||
const length = editor.textContent?.length || 0;
|
||||
WysiwygSelection.setCursorPosition(editor, length);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
editor?.focus();
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
|
||||
const editor = element.querySelector('.code-editor') as HTMLElement;
|
||||
if (!editor) return;
|
||||
|
||||
editor.focus();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element);
|
||||
} else if (typeof position === 'number') {
|
||||
WysiwygSelection.setCursorPosition(editor, position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement): { before: string; after: string } | null {
|
||||
const position = this.getCursorPosition(element);
|
||||
if (position === null) return null;
|
||||
|
||||
const content = this.getContent(element);
|
||||
return {
|
||||
before: content.substring(0, position),
|
||||
after: content.substring(position)
|
||||
};
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Code Block Container - Minimalist shadcn style */
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
margin: 12px 0;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.code-block-container.selected {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.code-block-container.editing {
|
||||
border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
}
|
||||
|
||||
/* Header - Simplified */
|
||||
.code-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-selector {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.language-selector:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.language-selector:focus {
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
/* Copy Button - Minimal */
|
||||
.copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 12px;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.copy-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.copy-button.copied {
|
||||
color: ${cssManager.bdTheme('#059669', '#10b981')};
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.copy-button:hover .copy-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-text {
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Code Body */
|
||||
.code-body {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
|
||||
}
|
||||
|
||||
/* Line Numbers - Subtle */
|
||||
.line-numbers {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 0;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
min-width: 40px;
|
||||
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
}
|
||||
|
||||
.line-number {
|
||||
padding: 0 12px 0 8px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
font-family: ${cssMonoFontFamily};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Code Content */
|
||||
.code-content {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
display: block;
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
font-family: ${cssMonoFontFamily};
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
min-height: 60px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.code-editor:empty::before {
|
||||
content: "// Type or paste code here...";
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* When editing (focused), show grey text without highlighting */
|
||||
.code-block-container.editing .code-editor {
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')} !important;
|
||||
}
|
||||
|
||||
.code-block-container.editing .code-editor * {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* Syntax Highlighting - Muted colors */
|
||||
.code-editor .hljs-keyword {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-editor .hljs-string {
|
||||
color: ${cssManager.bdTheme('#059669', '#10b981')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-number {
|
||||
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-function {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-comment {
|
||||
color: ${cssManager.bdTheme('#6b7280', '#6b7280')};
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-editor .hljs-variable,
|
||||
.code-editor .hljs-attr {
|
||||
color: ${cssManager.bdTheme('#ea580c', '#fb923c')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-class,
|
||||
.code-editor .hljs-title {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-editor .hljs-params {
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-built_in {
|
||||
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-literal {
|
||||
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-meta {
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-punctuation {
|
||||
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-tag {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-attribute {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-selector-tag {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-selector-class {
|
||||
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
|
||||
}
|
||||
|
||||
.code-editor .hljs-selector-id {
|
||||
color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')};
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
.code-editor::selection,
|
||||
.code-editor *::selection {
|
||||
background: ${cssManager.bdTheme('rgba(99, 102, 241, 0.2)', 'rgba(99, 102, 241, 0.3)')};
|
||||
}
|
||||
|
||||
/* Scrollbar styling - Minimal */
|
||||
.code-content::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.code-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.code-content::-webkit-scrollbar-thumb {
|
||||
background: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.code-content::-webkit-scrollbar-thumb:hover {
|
||||
background: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||
|
||||
export class HeadingBlockHandler extends BaseBlockHandler {
|
||||
type: string;
|
||||
private level: 1 | 2 | 3;
|
||||
|
||||
// Track cursor position
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
private lastSelectedText: string = '';
|
||||
private selectionHandler: (() => void) | null = null;
|
||||
|
||||
constructor(type: 'heading-1' | 'heading-2' | 'heading-3') {
|
||||
super();
|
||||
this.type = type;
|
||||
this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3;
|
||||
}
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const selectedClass = isSelected ? ' selected' : '';
|
||||
const placeholder = this.getPlaceholder();
|
||||
|
||||
|
||||
return `
|
||||
<div
|
||||
class="block heading-${this.level}${selectedClass}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
data-block-id="${block.id}"
|
||||
data-block-type="${block.type}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) {
|
||||
console.error('HeadingBlockHandler.setup: No heading block element found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Set initial content if needed
|
||||
if (block.content && !headingBlock.innerHTML) {
|
||||
headingBlock.innerHTML = block.content;
|
||||
}
|
||||
|
||||
// Input handler with cursor tracking
|
||||
headingBlock.addEventListener('input', (e) => {
|
||||
handlers.onInput(e as InputEvent);
|
||||
|
||||
// Track cursor position after input
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown handler with cursor tracking
|
||||
headingBlock.addEventListener('keydown', (e) => {
|
||||
// Track cursor position before keydown
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Focus handler
|
||||
headingBlock.addEventListener('focus', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// Blur handler
|
||||
headingBlock.addEventListener('blur', () => {
|
||||
handlers.onBlur();
|
||||
});
|
||||
|
||||
// Composition handlers for IME support
|
||||
headingBlock.addEventListener('compositionstart', () => {
|
||||
handlers.onCompositionStart();
|
||||
});
|
||||
|
||||
headingBlock.addEventListener('compositionend', () => {
|
||||
handlers.onCompositionEnd();
|
||||
});
|
||||
|
||||
// Mouse up handler
|
||||
headingBlock.addEventListener('mouseup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Selection will be handled by selectionchange event
|
||||
handlers.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
// Click handler with delayed cursor tracking
|
||||
headingBlock.addEventListener('click', (e: MouseEvent) => {
|
||||
// Small delay to let browser set cursor position
|
||||
setTimeout(() => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Keyup handler for additional cursor tracking
|
||||
headingBlock.addEventListener('keyup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up selection change handler
|
||||
this.setupSelectionHandler(element, headingBlock, block);
|
||||
}
|
||||
|
||||
private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void {
|
||||
// Add selection change handler
|
||||
const checkSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length === 0) {
|
||||
// Clear selection if no text
|
||||
if (this.lastSelectedText) {
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent wysiwyg component's shadow root - in setup, we need to traverse
|
||||
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||
|
||||
// Use getComposedRanges with shadow roots as per MDN docs
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
// Get selection info using our Shadow DOM-aware utility
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return;
|
||||
|
||||
// Check if selection is within this block
|
||||
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer);
|
||||
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer);
|
||||
|
||||
if (startInBlock || endInBlock) {
|
||||
if (selectedText !== this.lastSelectedText) {
|
||||
this.lastSelectedText = selectedText;
|
||||
|
||||
// Create range and get rect
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: selectedText.trim(),
|
||||
blockId: block.id,
|
||||
range: range,
|
||||
rect: rect,
|
||||
hasSelection: true
|
||||
});
|
||||
}
|
||||
} else if (this.lastSelectedText) {
|
||||
// Clear selection if no longer in this block
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionchange', checkSelection);
|
||||
|
||||
// Store the handler for cleanup
|
||||
this.selectionHandler = checkSelection;
|
||||
|
||||
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||
const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any;
|
||||
if (wysiwygBlock) {
|
||||
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||
if (this.selectionHandler) {
|
||||
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||
this.selectionHandler = null;
|
||||
}
|
||||
if (originalDisconnectedCallback) {
|
||||
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||
const event = new CustomEvent('block-text-selected', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
// Return styles for all heading levels
|
||||
return `
|
||||
.block.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 24px 0 8px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 20px 0 6px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
|
||||
.block.heading-3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 16px 0 4px 0;
|
||||
color: ${cssManager.bdTheme('#000000', '#ffffff')};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
getPlaceholder(): string {
|
||||
switch(this.level) {
|
||||
case 1:
|
||||
return 'Heading 1';
|
||||
case 2:
|
||||
return 'Heading 2';
|
||||
case 3:
|
||||
return 'Heading 3';
|
||||
default:
|
||||
return 'Heading';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper methods for heading functionality (mostly the same as paragraph)
|
||||
|
||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||
// Get the actual heading element
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a range from start of element to cursor position
|
||||
const preCaretRange = document.createRange();
|
||||
preCaretRange.selectNodeContents(headingBlock);
|
||||
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// Get the text content length up to cursor
|
||||
const position = preCaretRange.toString().length;
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement, context?: any): string {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) return '';
|
||||
|
||||
// For headings, get the innerHTML which includes formatting tags
|
||||
const content = headingBlock.innerHTML || '';
|
||||
return content;
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === headingBlock ||
|
||||
element.shadowRoot?.activeElement === headingBlock;
|
||||
|
||||
headingBlock.innerHTML = content;
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
headingBlock.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (headingBlock) {
|
||||
WysiwygBlocks.setCursorToStart(headingBlock);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (headingBlock) {
|
||||
WysiwygBlocks.setCursorToEnd(headingBlock);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement, context?: any): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!headingBlock.hasAttribute('contenteditable')) {
|
||||
headingBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
headingBlock.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) {
|
||||
Promise.resolve().then(() => {
|
||||
headingBlock.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) return;
|
||||
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!headingBlock.hasAttribute('contenteditable')) {
|
||||
headingBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||
if (position === 'end' && headingBlock.textContent && headingBlock.textContent.length > 0) {
|
||||
// Set up the selection first
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
const range = document.createRange();
|
||||
const lastNode = this.getLastTextNode(headingBlock) || headingBlock;
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(lastNode);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
// Now focus the element
|
||||
headingBlock.focus();
|
||||
|
||||
// Set cursor position after focus is established (for non-end positions)
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end' && (!headingBlock.textContent || headingBlock.textContent.length === 0)) {
|
||||
// Only call setCursorToEnd for empty blocks
|
||||
this.setCursorToEnd(element, context);
|
||||
} else if (typeof position === 'number') {
|
||||
// Use the selection utility to set cursor position
|
||||
WysiwygSelection.setCursorPosition(headingBlock, position);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Try again with a small delay - sometimes focus needs more time
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) {
|
||||
setCursor();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||
const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement;
|
||||
if (!headingBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = headingBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the selection is within this block
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = headingBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get cursor position first
|
||||
const cursorPos = this.getCursorPosition(element, context);
|
||||
|
||||
if (cursorPos === null || cursorPos === 0) {
|
||||
// If cursor is at start or can't determine position, move all content
|
||||
return {
|
||||
before: '',
|
||||
after: headingBlock.innerHTML
|
||||
};
|
||||
}
|
||||
|
||||
// For HTML content, split using ranges to preserve formatting
|
||||
const beforeRange = document.createRange();
|
||||
const afterRange = document.createRange();
|
||||
|
||||
// Before range: from start of element to cursor
|
||||
beforeRange.setStart(headingBlock, 0);
|
||||
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// After range: from cursor to end of element
|
||||
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
afterRange.setEnd(headingBlock, headingBlock.childNodes.length);
|
||||
|
||||
// Extract HTML content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML strings
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(beforeFragment);
|
||||
const beforeHtml = tempDiv.innerHTML;
|
||||
|
||||
tempDiv.innerHTML = '';
|
||||
tempDiv.appendChild(afterFragment);
|
||||
const afterHtml = tempDiv.innerHTML;
|
||||
|
||||
return {
|
||||
before: beforeHtml,
|
||||
after: afterHtml
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||
|
||||
export class ListBlockHandler extends BaseBlockHandler {
|
||||
type = 'list';
|
||||
|
||||
// Track cursor position and list state
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
private lastSelectedText: string = '';
|
||||
private selectionHandler: (() => void) | null = null;
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const selectedClass = isSelected ? ' selected' : '';
|
||||
const listType = block.metadata?.listType || 'unordered';
|
||||
const listTag = listType === 'ordered' ? 'ol' : 'ul';
|
||||
|
||||
// Render list content
|
||||
const listContent = this.renderListContent(block.content, block.metadata);
|
||||
|
||||
return `
|
||||
<div
|
||||
class="block list${selectedClass}"
|
||||
contenteditable="true"
|
||||
data-block-id="${block.id}"
|
||||
data-block-type="${block.type}"
|
||||
>${listContent}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderListContent(content: string | undefined, metadata: any): string {
|
||||
if (!content) return '<ul><li></li></ul>';
|
||||
|
||||
const listType = metadata?.listType || 'unordered';
|
||||
const listTag = listType === 'ordered' ? 'ol' : 'ul';
|
||||
|
||||
// Split content by newlines to create list items
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
if (lines.length === 0) {
|
||||
return `<${listTag}><li></li></${listTag}>`;
|
||||
}
|
||||
|
||||
const listItems = lines.map(line => `<li>${line}</li>`).join('');
|
||||
return `<${listTag}>${listItems}</${listTag}>`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) {
|
||||
console.error('ListBlockHandler.setup: No list block element found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial content if needed
|
||||
if (block.content && !listBlock.innerHTML) {
|
||||
listBlock.innerHTML = this.renderListContent(block.content, block.metadata);
|
||||
}
|
||||
|
||||
// Input handler
|
||||
listBlock.addEventListener('input', (e) => {
|
||||
handlers.onInput(e as InputEvent);
|
||||
|
||||
// Track cursor position after input
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown handler
|
||||
listBlock.addEventListener('keydown', (e) => {
|
||||
// Track cursor position before keydown
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Special handling for Enter key in lists
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const currentLi = range.startContainer.parentElement?.closest('li');
|
||||
|
||||
if (currentLi && currentLi.textContent === '') {
|
||||
// Empty list item - exit list mode
|
||||
e.preventDefault();
|
||||
handlers.onKeyDown(e);
|
||||
return;
|
||||
}
|
||||
// Otherwise, let browser create new list item naturally
|
||||
}
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Focus handler
|
||||
listBlock.addEventListener('focus', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// Blur handler
|
||||
listBlock.addEventListener('blur', () => {
|
||||
handlers.onBlur();
|
||||
});
|
||||
|
||||
// Composition handlers for IME support
|
||||
listBlock.addEventListener('compositionstart', () => {
|
||||
handlers.onCompositionStart();
|
||||
});
|
||||
|
||||
listBlock.addEventListener('compositionend', () => {
|
||||
handlers.onCompositionEnd();
|
||||
});
|
||||
|
||||
// Mouse up handler
|
||||
listBlock.addEventListener('mouseup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
handlers.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
// Click handler
|
||||
listBlock.addEventListener('click', (e: MouseEvent) => {
|
||||
setTimeout(() => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Keyup handler
|
||||
listBlock.addEventListener('keyup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up selection handler
|
||||
this.setupSelectionHandler(element, listBlock, block);
|
||||
}
|
||||
|
||||
private setupSelectionHandler(element: HTMLElement, listBlock: HTMLDivElement, block: IBlock): void {
|
||||
const checkSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length === 0) {
|
||||
if (this.lastSelectedText) {
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent wysiwyg component's shadow root
|
||||
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return;
|
||||
|
||||
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer);
|
||||
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.endContainer);
|
||||
|
||||
if (startInBlock || endInBlock) {
|
||||
if (selectedText !== this.lastSelectedText) {
|
||||
this.lastSelectedText = selectedText;
|
||||
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: selectedText.trim(),
|
||||
blockId: block.id,
|
||||
range: range,
|
||||
rect: rect,
|
||||
hasSelection: true
|
||||
});
|
||||
}
|
||||
} else if (this.lastSelectedText) {
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('selectionchange', checkSelection);
|
||||
this.selectionHandler = checkSelection;
|
||||
|
||||
// Cleanup on disconnect
|
||||
const wysiwygBlock = (listBlock.getRootNode() as ShadowRoot).host as any;
|
||||
if (wysiwygBlock) {
|
||||
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||
if (this.selectionHandler) {
|
||||
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||
this.selectionHandler = null;
|
||||
}
|
||||
if (originalDisconnectedCallback) {
|
||||
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||
const event = new CustomEvent('block-text-selected', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* List specific styles */
|
||||
.block.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin: 4px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
getPlaceholder(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Helper methods for list functionality
|
||||
|
||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return null;
|
||||
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return null;
|
||||
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(listBlock, selectionInfo.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For lists, calculate position based on text content
|
||||
const preCaretRange = document.createRange();
|
||||
preCaretRange.selectNodeContents(listBlock);
|
||||
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
return preCaretRange.toString().length;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement, context?: any): string {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return '';
|
||||
|
||||
// Extract text content from list items
|
||||
const listItems = listBlock.querySelectorAll('li');
|
||||
const content = Array.from(listItems)
|
||||
.map(li => li.textContent || '')
|
||||
.join('\n');
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return;
|
||||
|
||||
const hadFocus = document.activeElement === listBlock ||
|
||||
element.shadowRoot?.activeElement === listBlock;
|
||||
|
||||
// Get current metadata to preserve list type
|
||||
const listElement = listBlock.querySelector('ul, ol');
|
||||
const isOrdered = listElement?.tagName === 'OL';
|
||||
|
||||
// Update content
|
||||
listBlock.innerHTML = this.renderListContent(content, { listType: isOrdered ? 'ordered' : 'unordered' });
|
||||
|
||||
if (hadFocus) {
|
||||
listBlock.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return;
|
||||
|
||||
const firstLi = listBlock.querySelector('li');
|
||||
if (firstLi) {
|
||||
const textNode = this.getFirstTextNode(firstLi);
|
||||
if (textNode) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
range.setStart(textNode, 0);
|
||||
range.setEnd(textNode, 0);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return;
|
||||
|
||||
const lastLi = listBlock.querySelector('li:last-child');
|
||||
if (lastLi) {
|
||||
const textNode = this.getLastTextNode(lastLi);
|
||||
if (textNode) {
|
||||
const range = document.createRange();
|
||||
const selection = window.getSelection();
|
||||
const textLength = textNode.textContent?.length || 0;
|
||||
range.setStart(textNode, textLength);
|
||||
range.setEnd(textNode, textLength);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFirstTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = 0; i < element.childNodes.length; i++) {
|
||||
const firstText = this.getFirstTextNode(element.childNodes[i]);
|
||||
if (firstText) return firstText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
focus(element: HTMLElement, context?: any): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return;
|
||||
|
||||
if (!listBlock.hasAttribute('contenteditable')) {
|
||||
listBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
listBlock.focus();
|
||||
|
||||
if (document.activeElement !== listBlock && element.shadowRoot?.activeElement !== listBlock) {
|
||||
Promise.resolve().then(() => {
|
||||
listBlock.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return;
|
||||
|
||||
if (!listBlock.hasAttribute('contenteditable')) {
|
||||
listBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
listBlock.focus();
|
||||
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element, context);
|
||||
} else if (typeof position === 'number') {
|
||||
// For numeric positions in lists, we need custom logic
|
||||
// This is complex due to list structure, so default to end
|
||||
this.setCursorToEnd(element, context);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === listBlock || element.shadowRoot?.activeElement === listBlock) {
|
||||
setCursor();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||
const listBlock = element.querySelector('.block.list') as HTMLDivElement;
|
||||
if (!listBlock) return null;
|
||||
|
||||
// For lists, we don't split content - instead let the keyboard handler
|
||||
// create a new paragraph block when Enter is pressed on empty list item
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||
|
||||
export class ParagraphBlockHandler extends BaseBlockHandler {
|
||||
type = 'paragraph';
|
||||
|
||||
// Track cursor position
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
private lastSelectedText: string = '';
|
||||
private selectionHandler: (() => void) | null = null;
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const selectedClass = isSelected ? ' selected' : '';
|
||||
const placeholder = this.getPlaceholder();
|
||||
|
||||
|
||||
return `
|
||||
<div
|
||||
class="block paragraph${selectedClass}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
data-block-id="${block.id}"
|
||||
data-block-type="${block.type}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) {
|
||||
console.error('ParagraphBlockHandler.setup: No paragraph block element found');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Set initial content if needed
|
||||
if (block.content && !paragraphBlock.innerHTML) {
|
||||
paragraphBlock.innerHTML = block.content;
|
||||
}
|
||||
|
||||
// Input handler with cursor tracking
|
||||
paragraphBlock.addEventListener('input', (e) => {
|
||||
handlers.onInput(e as InputEvent);
|
||||
|
||||
// Track cursor position after input
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown handler with cursor tracking
|
||||
paragraphBlock.addEventListener('keydown', (e) => {
|
||||
// Track cursor position before keydown
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Focus handler
|
||||
paragraphBlock.addEventListener('focus', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// Blur handler
|
||||
paragraphBlock.addEventListener('blur', () => {
|
||||
handlers.onBlur();
|
||||
});
|
||||
|
||||
// Composition handlers for IME support
|
||||
paragraphBlock.addEventListener('compositionstart', () => {
|
||||
handlers.onCompositionStart();
|
||||
});
|
||||
|
||||
paragraphBlock.addEventListener('compositionend', () => {
|
||||
handlers.onCompositionEnd();
|
||||
});
|
||||
|
||||
// Mouse up handler
|
||||
paragraphBlock.addEventListener('mouseup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Selection will be handled by selectionchange event
|
||||
handlers.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
// Click handler with delayed cursor tracking
|
||||
paragraphBlock.addEventListener('click', (e: MouseEvent) => {
|
||||
// Small delay to let browser set cursor position
|
||||
setTimeout(() => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Keyup handler for additional cursor tracking
|
||||
paragraphBlock.addEventListener('keyup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up selection change handler
|
||||
this.setupSelectionHandler(element, paragraphBlock, block);
|
||||
}
|
||||
|
||||
private setupSelectionHandler(element: HTMLElement, paragraphBlock: HTMLDivElement, block: IBlock): void {
|
||||
// Add selection change handler
|
||||
const checkSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length === 0) {
|
||||
// Clear selection if no text
|
||||
if (this.lastSelectedText) {
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent wysiwyg component's shadow root - traverse from shadow root
|
||||
const wysiwygBlock = (paragraphBlock.getRootNode() as ShadowRoot).host as any;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||
|
||||
// Use getComposedRanges with shadow roots as per MDN docs
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
// Get selection info using our Shadow DOM-aware utility
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return;
|
||||
|
||||
// Check if selection is within this block
|
||||
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer);
|
||||
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.endContainer);
|
||||
|
||||
if (startInBlock || endInBlock) {
|
||||
if (selectedText !== this.lastSelectedText) {
|
||||
this.lastSelectedText = selectedText;
|
||||
|
||||
// Create range and get rect
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: selectedText.trim(),
|
||||
blockId: block.id,
|
||||
range: range,
|
||||
rect: rect,
|
||||
hasSelection: true
|
||||
});
|
||||
}
|
||||
} else if (this.lastSelectedText) {
|
||||
// Clear selection if no longer in this block
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionchange', checkSelection);
|
||||
|
||||
// Store the handler for cleanup
|
||||
this.selectionHandler = checkSelection;
|
||||
|
||||
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||
const wysiwygBlock = element.closest('dees-wysiwyg-block');
|
||||
if (wysiwygBlock) {
|
||||
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||
if (this.selectionHandler) {
|
||||
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||
this.selectionHandler = null;
|
||||
}
|
||||
if (originalDisconnectedCallback) {
|
||||
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||
const event = new CustomEvent('block-text-selected', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Paragraph specific styles */
|
||||
.block.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
getPlaceholder(): string {
|
||||
return "Type '/' for commands...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper methods for paragraph functionality
|
||||
|
||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||
// Get the actual paragraph element
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a range from start of element to cursor position
|
||||
const preCaretRange = document.createRange();
|
||||
preCaretRange.selectNodeContents(paragraphBlock);
|
||||
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// Get the text content length up to cursor
|
||||
const position = preCaretRange.toString().length;
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement, context?: any): string {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) return '';
|
||||
|
||||
// For paragraphs, get the innerHTML which includes formatting tags
|
||||
const content = paragraphBlock.innerHTML || '';
|
||||
return content;
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === paragraphBlock ||
|
||||
element.shadowRoot?.activeElement === paragraphBlock;
|
||||
|
||||
paragraphBlock.innerHTML = content;
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
paragraphBlock.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (paragraphBlock) {
|
||||
WysiwygBlocks.setCursorToStart(paragraphBlock);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (paragraphBlock) {
|
||||
WysiwygBlocks.setCursorToEnd(paragraphBlock);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement, context?: any): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
||||
paragraphBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
paragraphBlock.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== paragraphBlock && element.shadowRoot?.activeElement !== paragraphBlock) {
|
||||
Promise.resolve().then(() => {
|
||||
paragraphBlock.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) return;
|
||||
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!paragraphBlock.hasAttribute('contenteditable')) {
|
||||
paragraphBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// For 'end' position, we need to set up selection before focus to prevent browser default
|
||||
if (position === 'end' && paragraphBlock.textContent && paragraphBlock.textContent.length > 0) {
|
||||
// Set up the selection first
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
const range = document.createRange();
|
||||
const lastNode = this.getLastTextNode(paragraphBlock) || paragraphBlock;
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
range.setEnd(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(lastNode);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
// Now focus the element
|
||||
paragraphBlock.focus();
|
||||
|
||||
// Set cursor position after focus is established (for non-end positions)
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end' && (!paragraphBlock.textContent || paragraphBlock.textContent.length === 0)) {
|
||||
// Only call setCursorToEnd for empty blocks
|
||||
this.setCursorToEnd(element, context);
|
||||
} else if (typeof position === 'number') {
|
||||
// Use the selection utility to set cursor position
|
||||
WysiwygSelection.setCursorPosition(paragraphBlock, position);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Try again with a small delay - sometimes focus needs more time
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === paragraphBlock || element.shadowRoot?.activeElement === paragraphBlock) {
|
||||
setCursor();
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||
const paragraphBlock = element.querySelector('.block.paragraph') as HTMLDivElement;
|
||||
if (!paragraphBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = paragraphBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the selection is within this block
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(paragraphBlock, selectionInfo.startContainer)) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = paragraphBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get cursor position first
|
||||
const cursorPos = this.getCursorPosition(element, context);
|
||||
|
||||
if (cursorPos === null || cursorPos === 0) {
|
||||
// If cursor is at start or can't determine position, move all content
|
||||
return {
|
||||
before: '',
|
||||
after: paragraphBlock.innerHTML
|
||||
};
|
||||
}
|
||||
|
||||
// For HTML content, split using ranges to preserve formatting
|
||||
const beforeRange = document.createRange();
|
||||
const afterRange = document.createRange();
|
||||
|
||||
// Before range: from start of element to cursor
|
||||
beforeRange.setStart(paragraphBlock, 0);
|
||||
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// After range: from cursor to end of element
|
||||
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
afterRange.setEnd(paragraphBlock, paragraphBlock.childNodes.length);
|
||||
|
||||
// Extract HTML content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML strings
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(beforeFragment);
|
||||
const beforeHtml = tempDiv.innerHTML;
|
||||
|
||||
tempDiv.innerHTML = '';
|
||||
tempDiv.appendChild(afterFragment);
|
||||
const afterHtml = tempDiv.innerHTML;
|
||||
|
||||
return {
|
||||
before: beforeHtml,
|
||||
after: afterHtml
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
|
||||
import type { IBlock } from '../../wysiwyg.types.js';
|
||||
import { cssManager } from '@design.estate/dees-element';
|
||||
import { WysiwygBlocks } from '../../wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from '../../wysiwyg.selection.js';
|
||||
|
||||
export class QuoteBlockHandler extends BaseBlockHandler {
|
||||
type = 'quote';
|
||||
|
||||
// Track cursor position
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
private lastSelectedText: string = '';
|
||||
private selectionHandler: (() => void) | null = null;
|
||||
|
||||
render(block: IBlock, isSelected: boolean): string {
|
||||
const selectedClass = isSelected ? ' selected' : '';
|
||||
const placeholder = this.getPlaceholder();
|
||||
|
||||
|
||||
return `
|
||||
<div
|
||||
class="block quote${selectedClass}"
|
||||
contenteditable="true"
|
||||
data-placeholder="${placeholder}"
|
||||
data-block-id="${block.id}"
|
||||
data-block-type="${block.type}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) {
|
||||
console.error('QuoteBlockHandler.setup: No quote block element found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial content if needed
|
||||
if (block.content && !quoteBlock.innerHTML) {
|
||||
quoteBlock.innerHTML = block.content;
|
||||
}
|
||||
|
||||
// Input handler with cursor tracking
|
||||
quoteBlock.addEventListener('input', (e) => {
|
||||
handlers.onInput(e as InputEvent);
|
||||
|
||||
// Track cursor position after input
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Keydown handler with cursor tracking
|
||||
quoteBlock.addEventListener('keydown', (e) => {
|
||||
// Track cursor position before keydown
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
handlers.onKeyDown(e);
|
||||
});
|
||||
|
||||
// Focus handler
|
||||
quoteBlock.addEventListener('focus', () => {
|
||||
handlers.onFocus();
|
||||
});
|
||||
|
||||
// Blur handler
|
||||
quoteBlock.addEventListener('blur', () => {
|
||||
handlers.onBlur();
|
||||
});
|
||||
|
||||
// Composition handlers for IME support
|
||||
quoteBlock.addEventListener('compositionstart', () => {
|
||||
handlers.onCompositionStart();
|
||||
});
|
||||
|
||||
quoteBlock.addEventListener('compositionend', () => {
|
||||
handlers.onCompositionEnd();
|
||||
});
|
||||
|
||||
// Mouse up handler
|
||||
quoteBlock.addEventListener('mouseup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Selection will be handled by selectionchange event
|
||||
handlers.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
// Click handler with delayed cursor tracking
|
||||
quoteBlock.addEventListener('click', (e: MouseEvent) => {
|
||||
// Small delay to let browser set cursor position
|
||||
setTimeout(() => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Keyup handler for additional cursor tracking
|
||||
quoteBlock.addEventListener('keyup', (e) => {
|
||||
const pos = this.getCursorPosition(element);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Set up selection change handler
|
||||
this.setupSelectionHandler(element, quoteBlock, block);
|
||||
}
|
||||
|
||||
private setupSelectionHandler(element: HTMLElement, quoteBlock: HTMLDivElement, block: IBlock): void {
|
||||
// Add selection change handler
|
||||
const checkSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (selectedText.length === 0) {
|
||||
// Clear selection if no text
|
||||
if (this.lastSelectedText) {
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get parent wysiwyg component's shadow root - traverse from shadow root
|
||||
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = wysiwygBlock?.shadowRoot;
|
||||
|
||||
// Use getComposedRanges with shadow roots as per MDN docs
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
// Get selection info using our Shadow DOM-aware utility
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo) return;
|
||||
|
||||
// Check if selection is within this block
|
||||
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer);
|
||||
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.endContainer);
|
||||
|
||||
if (startInBlock || endInBlock) {
|
||||
if (selectedText !== this.lastSelectedText) {
|
||||
this.lastSelectedText = selectedText;
|
||||
|
||||
// Create range and get rect
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Dispatch event
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: selectedText.trim(),
|
||||
blockId: block.id,
|
||||
range: range,
|
||||
rect: rect,
|
||||
hasSelection: true
|
||||
});
|
||||
}
|
||||
} else if (this.lastSelectedText) {
|
||||
// Clear selection if no longer in this block
|
||||
this.lastSelectedText = '';
|
||||
this.dispatchSelectionEvent(element, {
|
||||
text: '',
|
||||
blockId: block.id,
|
||||
hasSelection: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener('selectionchange', checkSelection);
|
||||
|
||||
// Store the handler for cleanup
|
||||
this.selectionHandler = checkSelection;
|
||||
|
||||
// Clean up on disconnect (will be called by dees-wysiwyg-block)
|
||||
const wysiwygBlock = (quoteBlock.getRootNode() as ShadowRoot).host as any;
|
||||
if (wysiwygBlock) {
|
||||
const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback;
|
||||
(wysiwygBlock as any).disconnectedCallback = async function() {
|
||||
if (this.selectionHandler) {
|
||||
document.removeEventListener('selectionchange', this.selectionHandler);
|
||||
this.selectionHandler = null;
|
||||
}
|
||||
if (originalDisconnectedCallback) {
|
||||
await originalDisconnectedCallback.call(wysiwygBlock);
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchSelectionEvent(element: HTMLElement, detail: any): void {
|
||||
const event = new CustomEvent('block-text-selected', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true
|
||||
});
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
|
||||
getStyles(): string {
|
||||
return `
|
||||
/* Quote specific styles */
|
||||
.block.quote {
|
||||
border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
padding-left: 20px;
|
||||
color: ${cssManager.bdTheme('#555', '#b0b0b0')};
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
margin: 16px 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
getPlaceholder(): string {
|
||||
return 'Add a quote...';
|
||||
}
|
||||
|
||||
// Helper methods for quote functionality
|
||||
|
||||
getCursorPosition(element: HTMLElement, context?: any): number | null {
|
||||
// Get the actual quote element
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a range from start of element to cursor position
|
||||
const preCaretRange = document.createRange();
|
||||
preCaretRange.selectNodeContents(quoteBlock);
|
||||
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// Get the text content length up to cursor
|
||||
const position = preCaretRange.toString().length;
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
getContent(element: HTMLElement, context?: any): string {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) return '';
|
||||
|
||||
// For quotes, get the innerHTML which includes formatting tags
|
||||
const content = quoteBlock.innerHTML || '';
|
||||
return content;
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement, content: string, context?: any): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === quoteBlock ||
|
||||
element.shadowRoot?.activeElement === quoteBlock;
|
||||
|
||||
quoteBlock.innerHTML = content;
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
quoteBlock.focus();
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToStart(element: HTMLElement, context?: any): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (quoteBlock) {
|
||||
WysiwygBlocks.setCursorToStart(quoteBlock);
|
||||
}
|
||||
}
|
||||
|
||||
setCursorToEnd(element: HTMLElement, context?: any): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (quoteBlock) {
|
||||
WysiwygBlocks.setCursorToEnd(quoteBlock);
|
||||
}
|
||||
}
|
||||
|
||||
focus(element: HTMLElement, context?: any): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!quoteBlock.hasAttribute('contenteditable')) {
|
||||
quoteBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
quoteBlock.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== quoteBlock && element.shadowRoot?.activeElement !== quoteBlock) {
|
||||
Promise.resolve().then(() => {
|
||||
quoteBlock.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) return;
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!quoteBlock.hasAttribute('contenteditable')) {
|
||||
quoteBlock.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// Focus the element
|
||||
quoteBlock.focus();
|
||||
|
||||
// Set cursor position after focus is established
|
||||
const setCursor = () => {
|
||||
if (position === 'start') {
|
||||
this.setCursorToStart(element, context);
|
||||
} else if (position === 'end') {
|
||||
this.setCursorToEnd(element, context);
|
||||
} else if (typeof position === 'number') {
|
||||
// Use the selection utility to set cursor position
|
||||
WysiwygSelection.setCursorPosition(quoteBlock, position);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === quoteBlock || element.shadowRoot?.activeElement === quoteBlock) {
|
||||
setCursor();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null {
|
||||
const quoteBlock = element.querySelector('.block.quote') as HTMLDivElement;
|
||||
if (!quoteBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get shadow roots from context
|
||||
const wysiwygBlock = context?.component;
|
||||
const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg');
|
||||
const parentShadowRoot = parentComponent?.shadowRoot;
|
||||
const blockShadowRoot = context?.shadowRoot;
|
||||
|
||||
// Get selection info with both shadow roots for proper traversal
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
|
||||
if (blockShadowRoot) shadowRoots.push(blockShadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
if (!selectionInfo) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = quoteBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Make sure the selection is within this block
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(quoteBlock, selectionInfo.startContainer)) {
|
||||
// Try using last known cursor position
|
||||
if (this.lastKnownCursorPosition !== null) {
|
||||
const fullText = quoteBlock.textContent || '';
|
||||
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
|
||||
return {
|
||||
before: fullText.substring(0, pos),
|
||||
after: fullText.substring(pos)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get cursor position first
|
||||
const cursorPos = this.getCursorPosition(element, context);
|
||||
|
||||
if (cursorPos === null || cursorPos === 0) {
|
||||
// If cursor is at start or can't determine position, move all content
|
||||
return {
|
||||
before: '',
|
||||
after: quoteBlock.innerHTML
|
||||
};
|
||||
}
|
||||
|
||||
// For HTML content, split using ranges to preserve formatting
|
||||
const beforeRange = document.createRange();
|
||||
const afterRange = document.createRange();
|
||||
|
||||
// Before range: from start of element to cursor
|
||||
beforeRange.setStart(quoteBlock, 0);
|
||||
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
|
||||
// After range: from cursor to end of element
|
||||
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
afterRange.setEnd(quoteBlock, quoteBlock.childNodes.length);
|
||||
|
||||
// Extract HTML content
|
||||
const beforeFragment = beforeRange.cloneContents();
|
||||
const afterFragment = afterRange.cloneContents();
|
||||
|
||||
// Convert to HTML strings
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(beforeFragment);
|
||||
const beforeHtml = tempDiv.innerHTML;
|
||||
|
||||
tempDiv.innerHTML = '';
|
||||
tempDiv.appendChild(afterFragment);
|
||||
const afterHtml = tempDiv.innerHTML;
|
||||
|
||||
return {
|
||||
before: beforeHtml,
|
||||
after: afterHtml
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { zIndexRegistry } from '../../00zindex.js';
|
||||
|
||||
import { WysiwygFormatting } from './wysiwyg.formatting.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-formatting-menu': DeesFormattingMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-formatting-menu')
|
||||
export class DeesFormattingMenu extends DeesElement {
|
||||
private static instance: DeesFormattingMenu;
|
||||
|
||||
public static getInstance(): DeesFormattingMenu {
|
||||
if (!DeesFormattingMenu.instance) {
|
||||
DeesFormattingMenu.instance = new DeesFormattingMenu();
|
||||
document.body.appendChild(DeesFormattingMenu.instance);
|
||||
}
|
||||
return DeesFormattingMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor visible: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
@state()
|
||||
accessor menuZIndex: number = 1000;
|
||||
|
||||
private callback: ((command: string) => void | Promise<void>) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.formatting-menu {
|
||||
position: fixed;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#262626')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')};
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#000000', '#e0e0e0')};
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: ${cssManager.bdTheme('#f0f0f0', '#333333')};
|
||||
color: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
|
||||
}
|
||||
|
||||
.format-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.format-button.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-button.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-button.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-button .code-icon {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
// Apply z-index to host element
|
||||
this.style.zIndex = this.menuZIndex.toString();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="formatting-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
data-menu-type="formatting"
|
||||
>
|
||||
${WysiwygFormatting.formatButtons.map(button => html`
|
||||
<button
|
||||
class="format-button ${button.command}"
|
||||
data-command="${button.command}"
|
||||
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||
>
|
||||
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private applyFormat(command: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(command);
|
||||
}
|
||||
// Don't hide menu after applying format (except for link)
|
||||
if (command === 'link') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
|
||||
console.log('FormattingMenu.show called:', { position, visible: this.visible });
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
|
||||
// Get z-index from registry and apply immediately
|
||||
this.menuZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.menuZIndex);
|
||||
this.style.zIndex = this.menuZIndex.toString();
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
|
||||
// Unregister from z-index registry
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
public updatePosition(position: { x: number; y: number }): void {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
// Set up event delegation for the menu
|
||||
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const button = target.closest('.format-button') as HTMLElement;
|
||||
|
||||
if (button) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const command = button.getAttribute('data-command');
|
||||
if (command) {
|
||||
this.applyFormat(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.formatting-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { zIndexRegistry } from '../../00zindex.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
|
||||
import { type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-slash-menu': DeesSlashMenu;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-slash-menu')
|
||||
export class DeesSlashMenu extends DeesElement {
|
||||
private static instance: DeesSlashMenu;
|
||||
|
||||
public static getInstance(): DeesSlashMenu {
|
||||
if (!DeesSlashMenu.instance) {
|
||||
DeesSlashMenu.instance = new DeesSlashMenu();
|
||||
document.body.appendChild(DeesSlashMenu.instance);
|
||||
}
|
||||
return DeesSlashMenu.instance;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor visible: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor position: { x: number; y: number } = { x: 0, y: 0 };
|
||||
|
||||
@state()
|
||||
accessor filter: string = '';
|
||||
|
||||
@state()
|
||||
accessor selectedIndex: number = 0;
|
||||
|
||||
@state()
|
||||
accessor menuZIndex: number = 1000;
|
||||
|
||||
private callback: ((type: string) => void) | null = null;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: fixed;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
padding: 4px;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98) translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 3px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.visible) return html``;
|
||||
|
||||
// Ensure z-index is applied to host element
|
||||
this.style.zIndex = this.menuZIndex.toString();
|
||||
|
||||
const menuItems = this.getFilteredMenuItems();
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="slash-menu"
|
||||
style="left: ${this.position.x}px; top: ${this.position.y}px;"
|
||||
tabindex="-1"
|
||||
data-menu-type="slash"
|
||||
>
|
||||
${menuItems.map((item, index) => html`
|
||||
<div
|
||||
class="slash-menu-item ${index === this.selectedIndex ? 'selected' : ''}"
|
||||
data-item-type="${item.type}"
|
||||
data-item-index="${index}"
|
||||
>
|
||||
<dees-icon class="icon" .icon="${item.icon}" iconSize="16"></dees-icon>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getFilteredMenuItems(): ISlashMenuItem[] {
|
||||
const allItems = WysiwygShortcuts.getSlashMenuItems();
|
||||
return allItems.filter(item =>
|
||||
this.filter === '' ||
|
||||
item.label.toLowerCase().includes(this.filter.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
private selectItem(type: string): void {
|
||||
if (this.callback) {
|
||||
this.callback(type);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
|
||||
public show(position: { x: number; y: number }, callback: (type: string) => void): void {
|
||||
this.position = position;
|
||||
this.callback = callback;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
|
||||
// Get z-index from registry and apply immediately
|
||||
this.menuZIndex = zIndexRegistry.getNextZIndex();
|
||||
zIndexRegistry.register(this, this.menuZIndex);
|
||||
this.style.zIndex = this.menuZIndex.toString();
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.visible = false;
|
||||
this.callback = null;
|
||||
this.filter = '';
|
||||
this.selectedIndex = 0;
|
||||
|
||||
// Unregister from z-index registry
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
public updateFilter(filter: string): void {
|
||||
this.filter = filter;
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
public navigate(direction: 'up' | 'down'): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (direction === 'down') {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % items.length;
|
||||
} else {
|
||||
this.selectedIndex = this.selectedIndex === 0
|
||||
? items.length - 1
|
||||
: this.selectedIndex - 1;
|
||||
}
|
||||
}
|
||||
|
||||
public selectCurrent(): void {
|
||||
const items = this.getFilteredMenuItems();
|
||||
if (items[this.selectedIndex]) {
|
||||
this.selectItem(items[this.selectedIndex].type);
|
||||
}
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
// Set up event delegation
|
||||
this.shadowRoot?.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.slash-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent focus loss
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('click', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const itemType = menuItem.getAttribute('data-item-type');
|
||||
if (itemType) {
|
||||
this.selectItem(itemType);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.shadowRoot?.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const menuItem = target.closest('.slash-menu-item') as HTMLElement;
|
||||
|
||||
if (menuItem) {
|
||||
const index = parseInt(menuItem.getAttribute('data-item-index') || '0', 10);
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
|
||||
this.shadowRoot?.addEventListener('focus', (e: FocusEvent) => {
|
||||
const menu = this.shadowRoot?.querySelector('.slash-menu');
|
||||
if (menu && menu.contains(e.target as Node)) {
|
||||
// Prevent menu from taking focus
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, true); // Use capture phase
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
static as html,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js';
|
||||
import './wysiwyg.blockregistration.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import '../../dees-contextmenu/dees-contextmenu.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-wysiwyg-block': DeesWysiwygBlock;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('dees-wysiwyg-block')
|
||||
export class DeesWysiwygBlock extends DeesElement {
|
||||
async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
// Clean up selection handler
|
||||
if ((this as any)._selectionHandler) {
|
||||
document.removeEventListener('selectionchange', (this as any)._selectionHandler);
|
||||
}
|
||||
}
|
||||
@property({ type: Object })
|
||||
accessor block: IBlock;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor isSelected: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor handlers: IBlockEventHandlers;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor wysiwygComponent: any; // Reference to parent dees-input-wysiwyg
|
||||
|
||||
// Reference to the editable block element
|
||||
private blockElement: HTMLDivElement | null = null;
|
||||
|
||||
// Track if we've initialized the content
|
||||
private contentInitialized: boolean = false;
|
||||
|
||||
// Track cursor position
|
||||
private lastKnownCursorPosition: number = 0;
|
||||
private lastSelectedText: string = '';
|
||||
|
||||
private handlerStylesInjected = false;
|
||||
|
||||
// Block types that don't support contenteditable
|
||||
private static readonly NON_EDITABLE_TYPES = ['image', 'divider', 'youtube'];
|
||||
|
||||
private injectHandlerStyles(): void {
|
||||
// Only inject once per instance
|
||||
if (this.handlerStylesInjected) return;
|
||||
this.handlerStylesInjected = true;
|
||||
|
||||
// Get styles from all registered block handlers
|
||||
let styles = '';
|
||||
const blockTypes = BlockRegistry.getAllTypes();
|
||||
for (const type of blockTypes) {
|
||||
const handler = BlockRegistry.getHandler(type);
|
||||
if (handler) {
|
||||
styles += handler.getStyles();
|
||||
}
|
||||
}
|
||||
|
||||
if (styles) {
|
||||
// Create and inject style element
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = styles;
|
||||
this.shadowRoot?.appendChild(styleElement);
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
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-specific styles moved to handlers */
|
||||
|
||||
|
||||
/* 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 block container and language styles moved to handler */
|
||||
|
||||
/* Selection styles */
|
||||
.block ::selection {
|
||||
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')};
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
/* Strike through */
|
||||
.block :is(s, strike) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
/* 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(changedProperties: Map<string, any>): boolean {
|
||||
// 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;
|
||||
|
||||
if (element) {
|
||||
if (this.isSelected) {
|
||||
element.classList.add('selected');
|
||||
} else {
|
||||
element.classList.remove('selected');
|
||||
}
|
||||
}
|
||||
return false; // Don't re-render, just update the class
|
||||
}
|
||||
|
||||
// Never update if only the block content changed
|
||||
if (changedProperties.has('block') && this.block) {
|
||||
const oldBlock = changedProperties.get('block');
|
||||
if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) {
|
||||
// Only content or metadata changed, don't re-render
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if the block type or id changes
|
||||
return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType;
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
// Mark that content has been initialized
|
||||
this.contentInitialized = true;
|
||||
|
||||
// Inject handler styles if not already done
|
||||
this.injectHandlerStyles();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Check if we have a registered handler for this block type
|
||||
if (this.block) {
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler) {
|
||||
const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
if (blockElement) {
|
||||
handler.setup(blockElement, this.block, this.handlers);
|
||||
}
|
||||
return; // Block handler takes care of all setup
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special block types
|
||||
|
||||
// Now find the actual editable block element
|
||||
const editableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
// Ensure the block element maintains its content
|
||||
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);
|
||||
|
||||
// Track cursor position after input
|
||||
const pos = this.getCursorPosition(editableBlock);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('keydown', (e) => {
|
||||
// Track cursor position before keydown
|
||||
const pos = this.getCursorPosition(editableBlock);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
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) => {
|
||||
const pos = this.getCursorPosition(editableBlock);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
|
||||
// Selection will be handled by selectionchange event
|
||||
this.handlers?.onMouseUp?.(e);
|
||||
});
|
||||
|
||||
editableBlock.addEventListener('click', () => {
|
||||
// Small delay to let browser set cursor position
|
||||
setTimeout(() => {
|
||||
const pos = this.getCursorPosition(editableBlock);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// 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.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
|
||||
const startInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.startContainer);
|
||||
const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer);
|
||||
|
||||
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
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 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', () => {
|
||||
// Track cursor position
|
||||
const pos = this.getCursorPosition(editableBlock);
|
||||
if (pos !== null) {
|
||||
this.lastKnownCursorPosition = pos;
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial content if needed
|
||||
if (this.block.content) {
|
||||
editableBlock.innerHTML = this.block.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Store reference to the block element for quick access
|
||||
this.blockElement = editableBlock;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.block) return html``;
|
||||
|
||||
// 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 '';
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Default rendering for blocks without handlers
|
||||
const selectedClass = this.isSelected ? ' selected' : '';
|
||||
return `
|
||||
<div
|
||||
class="block ${this.block.type}${selectedClass}"
|
||||
contenteditable="true"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public focus(): void {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.focus) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.focus(container, context);
|
||||
}
|
||||
|
||||
// Handle non-editable blocks
|
||||
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
|
||||
const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement;
|
||||
if (blockElement) {
|
||||
blockElement.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual editable element
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
if (!editableElement) return;
|
||||
|
||||
// Ensure the element is focusable
|
||||
if (!editableElement.hasAttribute('contenteditable')) {
|
||||
editableElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
editableElement.focus();
|
||||
|
||||
// If focus failed, try again after a microtask
|
||||
if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
|
||||
Promise.resolve().then(() => {
|
||||
editableElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.focusWithCursor) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.focusWithCursor(container, position, context);
|
||||
}
|
||||
|
||||
// Non-editable blocks don't support cursor positioning
|
||||
if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) {
|
||||
this.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual editable element
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
if (!editableElement) return;
|
||||
|
||||
// Ensure element is focusable first
|
||||
if (!editableElement.hasAttribute('contenteditable')) {
|
||||
editableElement.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
// Focus the element
|
||||
editableElement.focus();
|
||||
|
||||
// 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') {
|
||||
// Use the new selection utility to set cursor position
|
||||
WysiwygSelection.setCursorPosition(editableElement, position);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure cursor is set after focus
|
||||
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
|
||||
setCursor();
|
||||
} else {
|
||||
// Wait for focus to be established
|
||||
Promise.resolve().then(() => {
|
||||
if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
|
||||
setCursor();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get cursor position in the editable element
|
||||
*/
|
||||
public getCursorPosition(element: HTMLElement): number | null {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.getCursorPosition) {
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.getCursorPosition(element, context);
|
||||
}
|
||||
|
||||
// Get parent wysiwyg component's shadow root
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
public getContent(): string {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.getContent) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.getContent(container, context);
|
||||
}
|
||||
|
||||
|
||||
// Get the actual editable element
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
if (!editableElement) return '';
|
||||
|
||||
// Get the innerHTML which includes formatting tags
|
||||
const content = editableElement.innerHTML || '';
|
||||
console.log('Getting content from block:', content);
|
||||
return content;
|
||||
}
|
||||
|
||||
public setContent(content: string): void {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.setContent) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.setContent(container, content, context);
|
||||
}
|
||||
|
||||
// Get the actual editable element
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
if (!editableElement) return;
|
||||
|
||||
// Store if we have focus
|
||||
const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
|
||||
|
||||
editableElement.innerHTML = content;
|
||||
|
||||
// Restore focus if we had it
|
||||
if (hadFocus) {
|
||||
editableElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public setCursorToStart(): void {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.setCursorToStart) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.setCursorToStart(container, context);
|
||||
}
|
||||
|
||||
// Always find the element fresh, don't rely on cached blockElement
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
if (editableElement) {
|
||||
WysiwygBlocks.setCursorToStart(editableElement);
|
||||
}
|
||||
}
|
||||
|
||||
public setCursorToEnd(): void {
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
if (handler && handler.setCursorToEnd) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
const context = { shadowRoot: this.shadowRoot!, component: this };
|
||||
return handler.setCursorToEnd(container, context);
|
||||
}
|
||||
|
||||
// Always find the element fresh, don't rely on cached blockElement
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
if (editableElement) {
|
||||
WysiwygBlocks.setCursorToEnd(editableElement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get context menu items for this block
|
||||
*/
|
||||
public getContextMenuItems(): any[] {
|
||||
if (!this.block || this.block.type === 'divider') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blockTypes = WysiwygShortcuts.getSlashMenuItems();
|
||||
const currentType = this.block.type;
|
||||
|
||||
// Use the parent reference passed from dees-input-wysiwyg
|
||||
const wysiwygComponent = this.wysiwygComponent;
|
||||
const blockId = this.block.id;
|
||||
|
||||
|
||||
// Create submenu items for block type change
|
||||
const blockTypeItems = blockTypes
|
||||
.filter(item => item.type !== currentType && item.type !== 'divider')
|
||||
.map(item => ({
|
||||
name: item.label,
|
||||
iconName: item.icon.replace('lucide:', ''),
|
||||
action: async () => {
|
||||
if (wysiwygComponent && wysiwygComponent.blockOperations) {
|
||||
// Transform the block type
|
||||
const blockToTransform = wysiwygComponent.blocks.find((b: IBlock) => b.id === blockId);
|
||||
if (blockToTransform) {
|
||||
blockToTransform.type = item.type;
|
||||
blockToTransform.content = blockToTransform.content || '';
|
||||
|
||||
// Handle special metadata for different block types
|
||||
if (item.type === 'code') {
|
||||
blockToTransform.metadata = { language: 'typescript' };
|
||||
} else if (item.type === 'list') {
|
||||
blockToTransform.metadata = { listType: 'bullet' };
|
||||
} else if (item.type === 'image') {
|
||||
blockToTransform.content = '';
|
||||
blockToTransform.metadata = { url: '', loading: false };
|
||||
} else if (item.type === 'youtube') {
|
||||
blockToTransform.content = '';
|
||||
blockToTransform.metadata = { videoId: '', url: '' };
|
||||
} else if (item.type === 'markdown') {
|
||||
blockToTransform.metadata = { showPreview: false };
|
||||
} else if (item.type === 'html') {
|
||||
blockToTransform.metadata = { showPreview: false };
|
||||
} else if (item.type === 'attachment') {
|
||||
blockToTransform.content = '';
|
||||
blockToTransform.metadata = { files: [] };
|
||||
}
|
||||
|
||||
// Update the block element
|
||||
wysiwygComponent.updateBlockElement(blockId);
|
||||
wysiwygComponent.updateValue();
|
||||
|
||||
// Focus the block after transformation
|
||||
requestAnimationFrame(() => {
|
||||
wysiwygComponent.blockOperations.focusBlock(blockId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const menuItems: any[] = [
|
||||
{
|
||||
name: 'Change Type',
|
||||
iconName: 'type',
|
||||
submenu: blockTypeItems
|
||||
}
|
||||
];
|
||||
|
||||
// Add copy/cut/paste for editable blocks
|
||||
if (!['image', 'divider', 'youtube', 'attachment'].includes(this.block.type)) {
|
||||
menuItems.push(
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Cut',
|
||||
iconName: 'scissors',
|
||||
shortcut: 'Cmd+X',
|
||||
action: async () => {
|
||||
document.execCommand('cut');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Copy',
|
||||
iconName: 'copy',
|
||||
shortcut: 'Cmd+C',
|
||||
action: async () => {
|
||||
document.execCommand('copy');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Paste',
|
||||
iconName: 'clipboard',
|
||||
shortcut: 'Cmd+V',
|
||||
action: async () => {
|
||||
document.execCommand('paste');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Add delete option
|
||||
menuItems.push(
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Delete Block',
|
||||
iconName: 'trash2',
|
||||
action: async () => {
|
||||
if (wysiwygComponent && wysiwygComponent.blockOperations) {
|
||||
wysiwygComponent.blockOperations.deleteBlock(blockId);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets content split at cursor position
|
||||
*/
|
||||
public getSplitContent(): { before: string; after: string } | null {
|
||||
console.log('getSplitContent: Starting...');
|
||||
|
||||
// Check if we have a registered handler for this block type
|
||||
const handler = BlockRegistry.getHandler(this.block.type);
|
||||
console.log('getSplitContent: Checking for handler', {
|
||||
blockType: this.block.type,
|
||||
hasHandler: !!handler,
|
||||
hasSplitMethod: !!(handler && handler.getSplitContent)
|
||||
});
|
||||
|
||||
if (handler && handler.getSplitContent) {
|
||||
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement;
|
||||
console.log('getSplitContent: Found container', {
|
||||
container: !!container,
|
||||
containerHTML: container?.innerHTML?.substring(0, 100)
|
||||
});
|
||||
const context = {
|
||||
shadowRoot: this.shadowRoot!,
|
||||
component: this
|
||||
};
|
||||
return handler.getSplitContent(container, context);
|
||||
}
|
||||
|
||||
|
||||
// Get the actual editable element first
|
||||
const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
|
||||
|
||||
if (!editableElement) {
|
||||
console.log('getSplitContent: No editable element found');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('getSplitContent: Element info:', {
|
||||
blockType: this.block.type,
|
||||
innerHTML: editableElement.innerHTML,
|
||||
textContent: editableElement.textContent,
|
||||
textLength: editableElement.textContent?.length
|
||||
});
|
||||
|
||||
// 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('getSplitContent: Selection info from shadow DOMs:', {
|
||||
selectionInfo,
|
||||
shadowRootsCount: shadowRoots.length
|
||||
});
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('getSplitContent: Selection range:', {
|
||||
startContainer: selectionInfo.startContainer,
|
||||
startOffset: selectionInfo.startOffset,
|
||||
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
|
||||
});
|
||||
|
||||
// Make sure the selection is within this block
|
||||
if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) {
|
||||
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)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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,
|
||||
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
|
||||
afterHtml,
|
||||
afterLength: afterHtml.length,
|
||||
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
|
||||
});
|
||||
|
||||
return {
|
||||
before: beforeHtml,
|
||||
after: afterHtml
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
20
ts_web/elements/00group-input/dees-input-wysiwyg/index.ts
Normal file
20
ts_web/elements/00group-input/dees-input-wysiwyg/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export * from './dees-input-wysiwyg.js';
|
||||
export * from './wysiwyg.types.js';
|
||||
export * from './wysiwyg.interfaces.js';
|
||||
export * from './wysiwyg.constants.js';
|
||||
export * from './wysiwyg.styles.js';
|
||||
export * from './wysiwyg.converters.js';
|
||||
export * from './wysiwyg.shortcuts.js';
|
||||
export * from './wysiwyg.formatting.js';
|
||||
export * from './wysiwyg.selection.js';
|
||||
export * from './wysiwyg.blocks.js';
|
||||
export * from './wysiwyg.blockoperations.js';
|
||||
export * from './wysiwyg.blockregistration.js';
|
||||
export * from './wysiwyg.inputhandler.js';
|
||||
export * from './wysiwyg.keyboardhandler.js';
|
||||
export * from './wysiwyg.dragdrophandler.js';
|
||||
export * from './wysiwyg.modalmanager.js';
|
||||
export * from './wysiwyg.history.js';
|
||||
export * from './dees-slash-menu.js';
|
||||
export * from './dees-formatting-menu.js';
|
||||
export * from './dees-wysiwyg-block.js';
|
||||
@@ -0,0 +1,61 @@
|
||||
# Phase 2 Implementation Summary - Divider Block Migration
|
||||
|
||||
## Overview
|
||||
Successfully migrated the divider block to the new block handler architecture as a proof of concept.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Created Block Handler
|
||||
- **File**: `blocks/content/divider.block.ts`
|
||||
- Implemented `DividerBlockHandler` class extending `BaseBlockHandler`
|
||||
- Extracted divider rendering logic from `dees-wysiwyg-block.ts`
|
||||
- Extracted divider setup logic (event handlers)
|
||||
- Extracted divider-specific styles
|
||||
|
||||
### 2. Registration System
|
||||
- **File**: `wysiwyg.blockregistration.ts`
|
||||
- Created registration module that registers all block handlers
|
||||
- Currently registers only the divider handler
|
||||
- Includes placeholders for future block types
|
||||
|
||||
### 3. Updated Block Component
|
||||
- **File**: `dees-wysiwyg-block.ts`
|
||||
- Added import for BlockRegistry and handler types
|
||||
- Modified `renderBlockContent()` to check registry first
|
||||
- Modified `firstUpdated()` to use registry for setup
|
||||
- Added `injectHandlerStyles()` method to inject handler styles dynamically
|
||||
- Removed hardcoded divider rendering logic
|
||||
- Removed hardcoded divider styles
|
||||
- Removed `setupDividerBlock()` method
|
||||
|
||||
### 4. Updated Exports
|
||||
- **File**: `blocks/index.ts`
|
||||
- Exported `DividerBlockHandler` class
|
||||
|
||||
## Key Features Preserved
|
||||
✅ Visual appearance with gradient and icon
|
||||
✅ Click to select behavior
|
||||
✅ Keyboard navigation support (Tab, Arrow keys)
|
||||
✅ Deletion with backspace/delete
|
||||
✅ Focus/blur handling
|
||||
✅ Proper styling for selected state
|
||||
|
||||
## Architecture Benefits
|
||||
1. **Modularity**: Each block type is now self-contained
|
||||
2. **Maintainability**: Block-specific logic is isolated
|
||||
3. **Extensibility**: Easy to add new block types
|
||||
4. **Type Safety**: Proper TypeScript interfaces
|
||||
5. **Code Reuse**: Common functionality in BaseBlockHandler
|
||||
|
||||
## Next Steps
|
||||
To migrate other block types, follow this pattern:
|
||||
1. Create handler file in appropriate folder (text/, media/, content/)
|
||||
2. Extract render logic, setup logic, and styles
|
||||
3. Register in `wysiwyg.blockregistration.ts`
|
||||
4. Remove hardcoded logic from `dees-wysiwyg-block.ts`
|
||||
5. Export from `blocks/index.ts`
|
||||
|
||||
## Testing
|
||||
- Project builds successfully without errors
|
||||
- Existing tests pass
|
||||
- Divider blocks render and function identically to before
|
||||
@@ -0,0 +1,75 @@
|
||||
# Phase 4 Implementation Summary - Heading Blocks Migration
|
||||
|
||||
## Overview
|
||||
Successfully migrated all heading blocks (h1, h2, h3) to the new block handler architecture using a unified HeadingBlockHandler class.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Created Unified Heading Handler
|
||||
- **File**: `blocks/text/heading.block.ts`
|
||||
- Implemented `HeadingBlockHandler` class extending `BaseBlockHandler`
|
||||
- Single handler class that accepts heading level (1, 2, or 3) in constructor
|
||||
- Extracted all heading rendering logic from `dees-wysiwyg-block.ts`
|
||||
- Extracted heading setup logic with full text editing support:
|
||||
- Input handling with cursor tracking
|
||||
- Selection handling with Shadow DOM support
|
||||
- Focus/blur management
|
||||
- Composition events for IME support
|
||||
- Split content functionality
|
||||
- Extracted all heading-specific styles for all three levels
|
||||
|
||||
### 2. Registration Updates
|
||||
- **File**: `wysiwyg.blockregistration.ts`
|
||||
- Registered three heading handlers using the same class:
|
||||
- `heading-1` → `new HeadingBlockHandler('heading-1')`
|
||||
- `heading-2` → `new HeadingBlockHandler('heading-2')`
|
||||
- `heading-3` → `new HeadingBlockHandler('heading-3')`
|
||||
- Updated imports to include HeadingBlockHandler
|
||||
|
||||
### 3. Updated Exports
|
||||
- **File**: `blocks/index.ts`
|
||||
- Exported `HeadingBlockHandler` class
|
||||
- Removed TODO comment for heading handler
|
||||
|
||||
### 4. Handler Implementation Details
|
||||
- **Dynamic Level Handling**: The handler determines the heading level from the block type
|
||||
- **Shared Styles**: All heading levels share the same style method but render different CSS
|
||||
- **Placeholder Support**: Each level has its own placeholder text
|
||||
- **Full Text Editing**: Inherits all paragraph-like functionality:
|
||||
- Cursor position tracking
|
||||
- Text selection with Shadow DOM awareness
|
||||
- Content splitting for Enter key handling
|
||||
- Focus management with cursor positioning
|
||||
|
||||
## Key Features Preserved
|
||||
✅ All three heading levels render with correct styles
|
||||
✅ Font sizes: h1 (32px), h2 (24px), h3 (20px)
|
||||
✅ Proper font weights and line heights
|
||||
✅ Theme-aware colors using cssManager.bdTheme
|
||||
✅ Contenteditable functionality
|
||||
✅ Selection and cursor tracking
|
||||
✅ Keyboard navigation
|
||||
✅ Focus/blur handling
|
||||
✅ Placeholder text for empty headings
|
||||
|
||||
## Architecture Benefits
|
||||
1. **Code Reuse**: Single handler class for all heading levels
|
||||
2. **Consistency**: All headings share the same behavior
|
||||
3. **Maintainability**: Changes to heading behavior only need to be made once
|
||||
4. **Type Safety**: Heading level is type-checked at construction
|
||||
5. **Scalability**: Easy to add more heading levels if needed
|
||||
|
||||
## Testing Results
|
||||
- ✅ TypeScript compilation successful
|
||||
- ✅ All three heading handlers registered correctly
|
||||
- ✅ Render method produces correct HTML with proper classes
|
||||
- ✅ Placeholders set correctly for each level
|
||||
- ✅ All handlers are instances of HeadingBlockHandler
|
||||
|
||||
## Next Steps
|
||||
Continue with Phase 5 to migrate remaining text blocks:
|
||||
- Quote block
|
||||
- Code block
|
||||
- List block
|
||||
|
||||
Each will follow the same pattern but with their specific requirements.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
|
||||
export class WysiwygBlockOperations {
|
||||
private component: IWysiwygComponent;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new block with the specified parameters
|
||||
*/
|
||||
createBlock(type: IBlock['type'] = 'paragraph', content: string = '', metadata?: any): IBlock {
|
||||
return {
|
||||
id: WysiwygShortcuts.generateBlockId(),
|
||||
type,
|
||||
content,
|
||||
...(metadata && { metadata })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a block after the specified block
|
||||
*/
|
||||
async insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock: boolean = true): Promise<void> {
|
||||
const blocks = this.component.blocks;
|
||||
const blockIndex = blocks.findIndex((b: IBlock) => b.id === afterBlock.id);
|
||||
|
||||
this.component.blocks = [
|
||||
...blocks.slice(0, blockIndex + 1),
|
||||
newBlock,
|
||||
...blocks.slice(blockIndex + 1)
|
||||
];
|
||||
|
||||
// Insert the new block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
const afterWrapper = this.component.editorContentRef.querySelector(`[data-block-id="${afterBlock.id}"]`);
|
||||
if (afterWrapper) {
|
||||
const newWrapper = this.component.createBlockElement(newBlock);
|
||||
afterWrapper.insertAdjacentElement('afterend', newWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
if (focusNewBlock && newBlock.type !== 'divider') {
|
||||
// Give DOM time to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Focus the new block
|
||||
await this.focusBlock(newBlock.id, 'start');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a block by its ID
|
||||
*/
|
||||
removeBlock(blockId: string): void {
|
||||
// Save checkpoint before deletion
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
|
||||
|
||||
// Remove the block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
const wrapper = this.component.editorContentRef.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (wrapper) {
|
||||
wrapper.remove();
|
||||
}
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a block by its ID
|
||||
*/
|
||||
findBlock(blockId: string): IBlock | undefined {
|
||||
return this.component.blocks.find((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the index of a block
|
||||
*/
|
||||
getBlockIndex(blockId: string): number {
|
||||
return this.component.blocks.findIndex((b: IBlock) => b.id === blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a specific block
|
||||
*/
|
||||
async focusBlock(blockId: string, cursorPosition: 'start' | 'end' | number = 'start'): Promise<void> {
|
||||
const wrapperElement = this.component.shadowRoot!.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (wrapperElement) {
|
||||
const blockComponent = wrapperElement.querySelector('dees-wysiwyg-block') as any;
|
||||
if (blockComponent) {
|
||||
// Wait a frame to ensure the block is rendered
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// Now focus with cursor position
|
||||
blockComponent.focusWithCursor(cursorPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content of a block
|
||||
*/
|
||||
updateBlockContent(blockId: string, content: string): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
block.content = content;
|
||||
this.component.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a block to a different type
|
||||
*/
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
|
||||
const block = this.findBlock(blockId);
|
||||
if (block) {
|
||||
// Save checkpoint before transformation
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
block.type = newType;
|
||||
block.content = '';
|
||||
if (metadata) {
|
||||
block.metadata = metadata;
|
||||
}
|
||||
|
||||
// Update the block element programmatically if we have the editor
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(blockId);
|
||||
}
|
||||
|
||||
this.component.updateValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a block to a new position
|
||||
*/
|
||||
moveBlock(blockId: string, targetIndex: number): void {
|
||||
const blocks = [...this.component.blocks];
|
||||
const currentIndex = this.getBlockIndex(blockId);
|
||||
|
||||
if (currentIndex === -1 || targetIndex < 0 || targetIndex >= blocks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [movedBlock] = blocks.splice(currentIndex, 1);
|
||||
blocks.splice(targetIndex, 0, movedBlock);
|
||||
|
||||
this.component.blocks = blocks;
|
||||
this.component.updateValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the previous block
|
||||
*/
|
||||
getPreviousBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index > 0 ? this.component.blocks[index - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next block
|
||||
*/
|
||||
getNextBlock(blockId: string): IBlock | null {
|
||||
const index = this.getBlockIndex(blockId);
|
||||
return index < this.component.blocks.length - 1 ? this.component.blocks[index + 1] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Block Registration Module
|
||||
* Handles registration of all block handlers with the BlockRegistry
|
||||
*
|
||||
* Phase 2 Complete: Divider block has been successfully migrated
|
||||
* to the new block handler architecture.
|
||||
* Phase 3 Complete: Paragraph block has been successfully migrated
|
||||
* to the new block handler architecture.
|
||||
* Phase 4 Complete: All heading blocks (h1, h2, h3) have been successfully migrated
|
||||
* to the new block handler architecture using a unified HeadingBlockHandler.
|
||||
* Phase 5 Complete: Quote, Code, and List blocks have been successfully migrated
|
||||
* to the new block handler architecture.
|
||||
* Phase 6 Complete: Image, YouTube, and Attachment blocks have been successfully migrated
|
||||
* to the new block handler architecture.
|
||||
* Phase 7 Complete: Markdown and HTML blocks have been successfully migrated
|
||||
* to the new block handler architecture.
|
||||
*/
|
||||
|
||||
import {
|
||||
BlockRegistry,
|
||||
DividerBlockHandler,
|
||||
ParagraphBlockHandler,
|
||||
HeadingBlockHandler,
|
||||
QuoteBlockHandler,
|
||||
CodeBlockHandler,
|
||||
ListBlockHandler,
|
||||
ImageBlockHandler,
|
||||
YouTubeBlockHandler,
|
||||
AttachmentBlockHandler,
|
||||
MarkdownBlockHandler,
|
||||
HtmlBlockHandler
|
||||
} from './blocks/index.js';
|
||||
|
||||
// Initialize and register all block handlers
|
||||
export function registerAllBlockHandlers(): void {
|
||||
// Register content blocks
|
||||
BlockRegistry.register('divider', new DividerBlockHandler());
|
||||
|
||||
// Register text blocks
|
||||
BlockRegistry.register('paragraph', new ParagraphBlockHandler());
|
||||
BlockRegistry.register('heading-1', new HeadingBlockHandler('heading-1'));
|
||||
BlockRegistry.register('heading-2', new HeadingBlockHandler('heading-2'));
|
||||
BlockRegistry.register('heading-3', new HeadingBlockHandler('heading-3'));
|
||||
BlockRegistry.register('quote', new QuoteBlockHandler());
|
||||
BlockRegistry.register('code', new CodeBlockHandler());
|
||||
BlockRegistry.register('list', new ListBlockHandler());
|
||||
|
||||
// Register media blocks
|
||||
BlockRegistry.register('image', new ImageBlockHandler());
|
||||
BlockRegistry.register('youtube', new YouTubeBlockHandler());
|
||||
BlockRegistry.register('attachment', new AttachmentBlockHandler());
|
||||
|
||||
// Register other content blocks
|
||||
BlockRegistry.register('markdown', new MarkdownBlockHandler());
|
||||
BlockRegistry.register('html', new HtmlBlockHandler());
|
||||
}
|
||||
|
||||
// Ensure blocks are registered when this module is imported
|
||||
registerAllBlockHandlers();
|
||||
@@ -0,0 +1,202 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygConverters } from './wysiwyg.converters.js';
|
||||
|
||||
export class WysiwygBlocks {
|
||||
static renderListContent(content: string, metadata?: any): string {
|
||||
const items = content.split('\n').filter(item => item.trim());
|
||||
if (items.length === 0) return '';
|
||||
const listTag = metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
// Don't escape HTML to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
|
||||
static renderBlock(
|
||||
block: IBlock,
|
||||
isSelected: boolean,
|
||||
handlers: {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
}
|
||||
): TemplateResult {
|
||||
if (block.type === 'divider') {
|
||||
return html`
|
||||
<div
|
||||
class="block divider"
|
||||
data-block-id="${block.id}"
|
||||
>
|
||||
<hr>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (block.type === 'list') {
|
||||
return html`
|
||||
<div
|
||||
class="block list ${isSelected ? 'selected' : ''}"
|
||||
data-block-id="${block.id}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.innerHTML="${this.renderListContent(block.content, block.metadata)}"
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Special rendering for code blocks with language indicator
|
||||
if (block.type === 'code') {
|
||||
const language = block.metadata?.language || 'plain text';
|
||||
return html`
|
||||
<div class="code-block-container">
|
||||
<div class="code-language">${language}</div>
|
||||
<div
|
||||
class="block ${block.type} ${isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.textContent="${block.content || ''}"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const blockElement = html`
|
||||
<div
|
||||
class="block ${block.type} ${isSelected ? 'selected' : ''}"
|
||||
contenteditable="true"
|
||||
@input="${handlers.onInput}"
|
||||
@keydown="${handlers.onKeyDown}"
|
||||
@focus="${handlers.onFocus}"
|
||||
@blur="${handlers.onBlur}"
|
||||
@compositionstart="${handlers.onCompositionStart}"
|
||||
@compositionend="${handlers.onCompositionEnd}"
|
||||
@mouseup="${(e: MouseEvent) => {
|
||||
console.log('Block mouseup event fired');
|
||||
if (handlers.onMouseUp) handlers.onMouseUp(e);
|
||||
}}"
|
||||
.innerHTML="${block.content || ''}"
|
||||
></div>
|
||||
`;
|
||||
|
||||
return blockElement;
|
||||
}
|
||||
|
||||
static setCursorToEnd(element: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element - add a zero-width space to enable cursor
|
||||
const textNode = document.createTextNode('\u200B');
|
||||
element.appendChild(textNode);
|
||||
range.setStart(textNode, 1);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the last text node or element
|
||||
const lastNode = this.getLastNode(element);
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(lastNode, lastNode.textContent?.length || 0);
|
||||
} else {
|
||||
range.setStartAfter(lastNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
// Remove zero-width space if it was added
|
||||
if (element.textContent === '\u200B') {
|
||||
element.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
static setCursorToStart(element: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
// Handle different content types
|
||||
if (element.childNodes.length === 0) {
|
||||
// Empty element
|
||||
range.setStart(element, 0);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
// Find the first text node or element
|
||||
const firstNode = this.getFirstNode(element);
|
||||
if (firstNode.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(firstNode, 0);
|
||||
} else {
|
||||
range.setStartBefore(firstNode);
|
||||
}
|
||||
range.collapse(true);
|
||||
}
|
||||
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
private static getLastNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const lastChild = element.childNodes[element.childNodes.length - 1];
|
||||
if (lastChild.nodeType === Node.TEXT_NODE || lastChild.childNodes.length === 0) {
|
||||
return lastChild;
|
||||
}
|
||||
|
||||
return this.getLastNode(lastChild);
|
||||
}
|
||||
|
||||
private static getFirstNode(element: Node): Node {
|
||||
if (element.childNodes.length === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const firstChild = element.childNodes[0];
|
||||
if (firstChild.nodeType === Node.TEXT_NODE || firstChild.childNodes.length === 0) {
|
||||
return firstChild;
|
||||
}
|
||||
|
||||
return this.getFirstNode(firstChild);
|
||||
}
|
||||
|
||||
static focusListItem(listElement: HTMLElement): void {
|
||||
const firstLi = listElement.querySelector('li');
|
||||
if (firstLi) {
|
||||
firstLi.focus();
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(firstLi);
|
||||
range.collapse(true);
|
||||
sel!.removeAllRanges();
|
||||
sel!.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Shared constants for the WYSIWYG editor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available programming languages for code blocks
|
||||
*/
|
||||
export const PROGRAMMING_LANGUAGES = [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Python',
|
||||
'Java',
|
||||
'C++',
|
||||
'C#',
|
||||
'Go',
|
||||
'Rust',
|
||||
'HTML',
|
||||
'CSS',
|
||||
'SQL',
|
||||
'Shell',
|
||||
'JSON',
|
||||
'YAML',
|
||||
'Markdown',
|
||||
'Plain Text'
|
||||
] as const;
|
||||
|
||||
export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number];
|
||||
@@ -0,0 +1,329 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygConverters {
|
||||
static escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
static formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
static getHtmlOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
// Check if content already contains HTML formatting
|
||||
const content = block.content.includes('<') && block.content.includes('>')
|
||||
? block.content // Already contains HTML formatting
|
||||
: this.escapeHtml(block.content);
|
||||
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content ? `<p>${content}</p>` : '';
|
||||
case 'heading-1':
|
||||
return `<h1>${content}</h1>`;
|
||||
case 'heading-2':
|
||||
return `<h2>${content}</h2>`;
|
||||
case 'heading-3':
|
||||
return `<h3>${content}</h3>`;
|
||||
case 'quote':
|
||||
return `<blockquote>${content}</blockquote>`;
|
||||
case 'code':
|
||||
return `<pre><code>${this.escapeHtml(block.content)}</code></pre>`;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (items.length > 0) {
|
||||
const listTag = block.metadata?.listType === 'ordered' ? 'ol' : 'ul';
|
||||
// Don't escape HTML in list items to preserve formatting
|
||||
return `<${listTag}>${items.map(item => `<li>${item}</li>`).join('')}</${listTag}>`;
|
||||
}
|
||||
return '';
|
||||
case 'divider':
|
||||
return '<hr>';
|
||||
case 'image':
|
||||
const imageUrl = block.metadata?.url;
|
||||
if (imageUrl) {
|
||||
const altText = this.escapeHtml(block.content || 'Image');
|
||||
return `<img src="${imageUrl}" alt="${altText}" />`;
|
||||
}
|
||||
return '';
|
||||
case 'youtube':
|
||||
const videoId = block.metadata?.videoId;
|
||||
if (videoId) {
|
||||
return `<iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
|
||||
}
|
||||
return '';
|
||||
case 'markdown':
|
||||
// Return the raw markdown content wrapped in a div
|
||||
return `<div class="markdown-content">${this.escapeHtml(block.content)}</div>`;
|
||||
case 'html':
|
||||
// Return the raw HTML content (already HTML)
|
||||
return block.content;
|
||||
case 'attachment':
|
||||
const files = block.metadata?.files || [];
|
||||
if (files.length > 0) {
|
||||
return `<div class="attachments">${files.map((file: any) =>
|
||||
`<div class="attachment-item" data-file-id="${file.id}">
|
||||
<a href="${file.data}" download="${file.name}">${this.escapeHtml(file.name)}</a>
|
||||
<span class="file-size">(${this.formatFileSize(file.size)})</span>
|
||||
</div>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
return '';
|
||||
default:
|
||||
return `<p>${content}</p>`;
|
||||
}
|
||||
}).filter(html => html !== '').join('\n');
|
||||
}
|
||||
|
||||
static getMarkdownOutput(blocks: IBlock[]): string {
|
||||
return blocks.map(block => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return block.content;
|
||||
case 'heading-1':
|
||||
return `# ${block.content}`;
|
||||
case 'heading-2':
|
||||
return `## ${block.content}`;
|
||||
case 'heading-3':
|
||||
return `### ${block.content}`;
|
||||
case 'quote':
|
||||
return `> ${block.content}`;
|
||||
case 'code':
|
||||
return `\`\`\`\n${block.content}\n\`\`\``;
|
||||
case 'list':
|
||||
const items = block.content.split('\n').filter(item => item.trim());
|
||||
if (block.metadata?.listType === 'ordered') {
|
||||
return items.map((item, index) => `${index + 1}. ${item}`).join('\n');
|
||||
} else {
|
||||
return items.map(item => `- ${item}`).join('\n');
|
||||
}
|
||||
case 'divider':
|
||||
return '---';
|
||||
case 'image':
|
||||
const imageUrl = block.metadata?.url;
|
||||
const altText = block.content || 'Image';
|
||||
return imageUrl ? `` : '';
|
||||
case 'youtube':
|
||||
const videoId = block.metadata?.videoId;
|
||||
const url = block.metadata?.url || (videoId ? `https://youtube.com/watch?v=${videoId}` : '');
|
||||
return url ? `[YouTube Video](${url})` : '';
|
||||
case 'markdown':
|
||||
// Return the raw markdown content
|
||||
return block.content;
|
||||
case 'html':
|
||||
// Return as HTML comment in markdown
|
||||
return `<!-- HTML Block\n${block.content}\n-->`;
|
||||
case 'attachment':
|
||||
const files = block.metadata?.files || [];
|
||||
if (files.length > 0) {
|
||||
return files.map((file: any) => `- [${file.name}](${file.data})`).join('\n');
|
||||
}
|
||||
return '';
|
||||
default:
|
||||
return block.content;
|
||||
}
|
||||
}).filter(md => md !== '').join('\n\n');
|
||||
}
|
||||
|
||||
static parseHtmlToBlocks(html: string): IBlock[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const blocks: IBlock[] = [];
|
||||
|
||||
const processNode = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: node.textContent.trim(),
|
||||
});
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
switch (tagName) {
|
||||
case 'p':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h1':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h2':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'h3':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'blockquote':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: element.innerHTML || '',
|
||||
});
|
||||
break;
|
||||
case 'pre':
|
||||
case 'code':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: element.textContent || '',
|
||||
});
|
||||
break;
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
const listItems = Array.from(element.querySelectorAll('li'));
|
||||
// Use innerHTML to preserve formatting
|
||||
const content = listItems.map(li => li.innerHTML || '').join('\n');
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: content,
|
||||
metadata: { listType: tagName === 'ol' ? 'ordered' : 'bullet' }
|
||||
});
|
||||
break;
|
||||
case 'hr':
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
break;
|
||||
case 'img':
|
||||
const imgElement = element as HTMLImageElement;
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'image',
|
||||
content: imgElement.alt || '',
|
||||
metadata: { url: imgElement.src }
|
||||
});
|
||||
break;
|
||||
default:
|
||||
// Process children for other elements
|
||||
element.childNodes.forEach(child => processNode(child));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
doc.body.childNodes.forEach(node => processNode(node));
|
||||
return blocks;
|
||||
}
|
||||
|
||||
static parseMarkdownToBlocks(markdown: string): IBlock[] {
|
||||
const lines = markdown.split('\n');
|
||||
const blocks: IBlock[] = [];
|
||||
let currentListItems: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('# ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-1',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('## ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-2',
|
||||
content: line.substring(3),
|
||||
});
|
||||
} else if (line.startsWith('### ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'heading-3',
|
||||
content: line.substring(4),
|
||||
});
|
||||
} else if (line.startsWith('> ')) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'quote',
|
||||
content: line.substring(2),
|
||||
});
|
||||
} else if (line.startsWith('```')) {
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'code',
|
||||
content: codeLines.join('\n'),
|
||||
});
|
||||
} else if (line.match(/^(\*|-) /)) {
|
||||
currentListItems.push(line.substring(2));
|
||||
// Check if next line is not a list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^(\*|-) /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'bullet' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line.match(/^\d+\. /)) {
|
||||
currentListItems.push(line.replace(/^\d+\. /, ''));
|
||||
// Check if next line is not a numbered list item
|
||||
if (i === lines.length - 1 || (!lines[i + 1].match(/^\d+\. /))) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'list',
|
||||
content: currentListItems.join('\n'),
|
||||
metadata: { listType: 'ordered' }
|
||||
});
|
||||
currentListItems = [];
|
||||
}
|
||||
} else if (line === '---' || line === '***' || line === '___') {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'divider',
|
||||
content: ' ',
|
||||
});
|
||||
} else if (line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/)) {
|
||||
// Parse markdown image syntax 
|
||||
const match = line.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
|
||||
if (match) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'image',
|
||||
content: match[1] || '',
|
||||
metadata: { url: match[2] }
|
||||
});
|
||||
}
|
||||
} else if (line.trim()) {
|
||||
blocks.push({
|
||||
id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
type: 'paragraph',
|
||||
content: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
|
||||
export class WysiwygDragDropHandler {
|
||||
private component: IWysiwygComponent;
|
||||
private draggedBlockId: string | null = null;
|
||||
private dragOverBlockId: string | null = null;
|
||||
private dragOverPosition: 'before' | 'after' | null = null;
|
||||
private dropIndicator: HTMLElement | null = null;
|
||||
private initialMouseY: number = 0;
|
||||
private initialBlockY: number = 0;
|
||||
private draggedBlockElement: HTMLElement | null = null;
|
||||
private draggedBlockHeight: number = 0;
|
||||
private draggedBlockContentHeight: number = 0;
|
||||
private draggedBlockMarginTop: number = 0;
|
||||
private lastUpdateTime: number = 0;
|
||||
private updateThrottle: number = 80; // milliseconds
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current drag state
|
||||
*/
|
||||
get dragState() {
|
||||
return {
|
||||
draggedBlockId: this.draggedBlockId,
|
||||
dragOverBlockId: this.dragOverBlockId,
|
||||
dragOverPosition: this.dragOverPosition
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag start
|
||||
*/
|
||||
handleDragStart(e: DragEvent, block: IBlock): void {
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
this.draggedBlockId = block.id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', block.id);
|
||||
|
||||
// Hide the default drag image
|
||||
const emptyImg = new Image();
|
||||
emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=';
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
// Store initial mouse position and block element
|
||||
this.initialMouseY = e.clientY;
|
||||
this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`);
|
||||
|
||||
|
||||
if (this.draggedBlockElement) {
|
||||
// Get the wrapper rect for measurements
|
||||
const rect = this.draggedBlockElement.getBoundingClientRect();
|
||||
this.initialBlockY = rect.top;
|
||||
|
||||
// Get the inner block element for proper measurements
|
||||
const innerBlock = this.draggedBlockElement.querySelector('.block');
|
||||
if (innerBlock) {
|
||||
const innerRect = innerBlock.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(innerBlock);
|
||||
this.draggedBlockMarginTop = parseInt(computedStyle.marginTop) || 0;
|
||||
this.draggedBlockContentHeight = innerRect.height;
|
||||
}
|
||||
|
||||
// The drop indicator should match the wrapper height exactly
|
||||
// The wrapper already includes all the space the block occupies
|
||||
this.draggedBlockHeight = rect.height;
|
||||
|
||||
console.log('Drag measurements:', {
|
||||
wrapperHeight: rect.height,
|
||||
marginTop: this.draggedBlockMarginTop,
|
||||
dropIndicatorHeight: this.draggedBlockHeight,
|
||||
contentHeight: this.draggedBlockContentHeight,
|
||||
blockId: block.id
|
||||
});
|
||||
|
||||
// Create drop indicator
|
||||
this.createDropIndicator();
|
||||
|
||||
// Set up drag event listeners
|
||||
document.addEventListener('dragover', this.handleGlobalDragOver);
|
||||
document.addEventListener('dragend', this.handleGlobalDragEnd);
|
||||
}
|
||||
|
||||
// Update component state
|
||||
this.component.draggedBlockId = this.draggedBlockId;
|
||||
|
||||
// Add dragging class after a small delay
|
||||
setTimeout(() => {
|
||||
if (this.draggedBlockElement) {
|
||||
this.draggedBlockElement.classList.add('dragging');
|
||||
}
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.editorContentRef.classList.add('dragging');
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag end
|
||||
*/
|
||||
handleDragEnd(): void {
|
||||
// Clean up visual state
|
||||
const allBlocks = this.component.editorContentRef.querySelectorAll('.block-wrapper');
|
||||
allBlocks.forEach((block: HTMLElement) => {
|
||||
block.classList.remove('dragging', 'move-up', 'move-down');
|
||||
block.style.removeProperty('--drag-offset');
|
||||
block.style.removeProperty('transform');
|
||||
});
|
||||
|
||||
// Remove dragging class from editor
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.editorContentRef.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
this.draggedBlockId = null;
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
this.draggedBlockElement = null;
|
||||
this.draggedBlockHeight = 0;
|
||||
this.draggedBlockContentHeight = 0;
|
||||
this.draggedBlockMarginTop = 0;
|
||||
this.initialBlockY = 0;
|
||||
|
||||
// Update component state
|
||||
this.component.draggedBlockId = null;
|
||||
this.component.dragOverBlockId = null;
|
||||
this.component.dragOverPosition = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag over
|
||||
*/
|
||||
handleDragOver(e: DragEvent, block: IBlock): void {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
this.dragOverBlockId = block.id;
|
||||
this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after';
|
||||
|
||||
// Update component state
|
||||
this.component.dragOverBlockId = this.dragOverBlockId;
|
||||
this.component.dragOverPosition = this.dragOverPosition;
|
||||
|
||||
// The parent component already handles drag-over classes programmatically
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drag leave
|
||||
*/
|
||||
handleDragLeave(block: IBlock): void {
|
||||
if (this.dragOverBlockId === block.id) {
|
||||
this.dragOverBlockId = null;
|
||||
this.dragOverPosition = null;
|
||||
|
||||
// Update component state
|
||||
this.component.dragOverBlockId = null;
|
||||
this.component.dragOverPosition = null;
|
||||
|
||||
// The parent component already handles removing drag-over classes programmatically
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles drop
|
||||
*/
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return;
|
||||
|
||||
// The parent component already has a handleDrop method that handles this programmatically
|
||||
// We'll delegate to that to ensure proper programmatic rendering
|
||||
this.component.handleDrop(e, targetBlock);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a block is being dragged
|
||||
*/
|
||||
isDragging(blockId: string): boolean {
|
||||
return this.draggedBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a block has drag over state
|
||||
*/
|
||||
isDragOver(blockId: string): boolean {
|
||||
return this.dragOverBlockId === blockId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets drag over CSS classes for a block
|
||||
*/
|
||||
getDragOverClasses(blockId: string): string {
|
||||
if (!this.isDragOver(blockId)) return '';
|
||||
return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates the drop indicator element
|
||||
*/
|
||||
private createDropIndicator(): void {
|
||||
this.dropIndicator = document.createElement('div');
|
||||
this.dropIndicator.className = 'drop-indicator';
|
||||
this.dropIndicator.style.display = 'none';
|
||||
this.component.editorContentRef.appendChild(this.dropIndicator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global dragover to update dragged block position and move other blocks
|
||||
*/
|
||||
private handleGlobalDragOver = (e: DragEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.draggedBlockElement) return;
|
||||
|
||||
// Calculate vertical offset from initial position
|
||||
const deltaY = e.clientY - this.initialMouseY;
|
||||
|
||||
// Apply transform to move the dragged block vertically
|
||||
this.draggedBlockElement.style.transform = `translateY(${deltaY}px)`;
|
||||
|
||||
// Throttle position updates to reduce stuttering
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < this.updateThrottle) {
|
||||
return;
|
||||
}
|
||||
this.lastUpdateTime = now;
|
||||
|
||||
// Calculate which blocks should move
|
||||
this.updateBlockPositions(e.clientY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates block positions based on cursor position
|
||||
*/
|
||||
private updateBlockPositions(mouseY: number): void {
|
||||
const blocks = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
|
||||
const draggedIndex = blocks.findIndex(b => b.getAttribute('data-block-id') === this.draggedBlockId);
|
||||
|
||||
if (draggedIndex === -1) return;
|
||||
|
||||
// Reset all transforms first (except the dragged block)
|
||||
blocks.forEach(block => {
|
||||
if (block.getAttribute('data-block-id') !== this.draggedBlockId) {
|
||||
block.classList.remove('move-up', 'move-down');
|
||||
block.style.removeProperty('--drag-offset');
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate where the dragged block should be inserted
|
||||
let newIndex = blocks.length; // Default to end
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue;
|
||||
|
||||
const block = blocks[i];
|
||||
const rect = block.getBoundingClientRect();
|
||||
const blockTop = rect.top;
|
||||
|
||||
// Check if mouse is above this block's middle
|
||||
if (mouseY < blockTop + (rect.height * 0.5)) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply transforms to move blocks out of the way
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue;
|
||||
|
||||
const block = blocks[i];
|
||||
|
||||
// Determine if this block needs to move
|
||||
if (draggedIndex < newIndex) {
|
||||
// Dragging down: blocks between original and new position move up
|
||||
if (i > draggedIndex && i < newIndex) {
|
||||
block.classList.add('move-up');
|
||||
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
|
||||
}
|
||||
} else if (draggedIndex > newIndex) {
|
||||
// Dragging up: blocks between new and original position move down
|
||||
if (i >= newIndex && i < draggedIndex) {
|
||||
block.classList.add('move-down');
|
||||
block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update drop indicator position
|
||||
this.updateDropIndicator(blocks, newIndex, draggedIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the drop indicator position
|
||||
*/
|
||||
private updateDropIndicator(blocks: HTMLElement[], targetIndex: number, draggedIndex: number): void {
|
||||
if (!this.dropIndicator || !this.draggedBlockElement) return;
|
||||
|
||||
this.dropIndicator.style.display = 'block';
|
||||
|
||||
const containerRect = this.component.editorContentRef.getBoundingClientRect();
|
||||
let topPosition = 0;
|
||||
|
||||
// Build array of visual block positions (excluding dragged block)
|
||||
const visualBlocks: { index: number, top: number, bottom: number }[] = [];
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
if (i === draggedIndex) continue; // Skip the dragged block
|
||||
|
||||
const block = blocks[i];
|
||||
const rect = block.getBoundingClientRect();
|
||||
let top = rect.top - containerRect.top;
|
||||
let bottom = rect.bottom - containerRect.top;
|
||||
|
||||
// Account for any transforms
|
||||
const transform = window.getComputedStyle(block).transform;
|
||||
if (transform && transform !== 'none') {
|
||||
const matrix = new DOMMatrix(transform);
|
||||
const yOffset = matrix.m42;
|
||||
top += yOffset;
|
||||
bottom += yOffset;
|
||||
}
|
||||
|
||||
visualBlocks.push({ index: i, top, bottom });
|
||||
}
|
||||
|
||||
// Sort by visual position
|
||||
visualBlocks.sort((a, b) => a.top - b.top);
|
||||
|
||||
// Adjust targetIndex to account for excluded dragged block
|
||||
let adjustedTargetIndex = targetIndex;
|
||||
if (targetIndex > draggedIndex) {
|
||||
adjustedTargetIndex--; // Reduce by 1 since dragged block is not in visualBlocks
|
||||
}
|
||||
|
||||
// Calculate drop position
|
||||
// Get the margin that will be applied based on the dragged block type
|
||||
let blockMargin = 16; // default margin
|
||||
if (this.draggedBlockElement) {
|
||||
const draggedBlock = this.component.blocks.find(b => b.id === this.draggedBlockId);
|
||||
if (draggedBlock) {
|
||||
const blockType = draggedBlock.type;
|
||||
if (blockType === 'heading-1' || blockType === 'heading-2' || blockType === 'heading-3') {
|
||||
blockMargin = 24;
|
||||
} else if (blockType === 'code' || blockType === 'quote') {
|
||||
blockMargin = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (adjustedTargetIndex === 0) {
|
||||
// Insert at the very top - no margin needed for first block
|
||||
topPosition = 0;
|
||||
} else if (adjustedTargetIndex >= visualBlocks.length) {
|
||||
// Insert at the end
|
||||
const lastBlock = visualBlocks[visualBlocks.length - 1];
|
||||
if (lastBlock) {
|
||||
topPosition = lastBlock.bottom;
|
||||
// Add margin that will be applied to the dropped block
|
||||
topPosition += blockMargin;
|
||||
}
|
||||
} else {
|
||||
// Insert between blocks
|
||||
const blockBefore = visualBlocks[adjustedTargetIndex - 1];
|
||||
if (blockBefore) {
|
||||
topPosition = blockBefore.bottom;
|
||||
// Add margin that will be applied to the dropped block
|
||||
topPosition += blockMargin;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the indicator height to match the dragged block
|
||||
this.dropIndicator.style.height = `${this.draggedBlockHeight}px`;
|
||||
|
||||
// Set position
|
||||
this.dropIndicator.style.top = `${Math.max(0, topPosition)}px`;
|
||||
|
||||
console.log('Drop indicator update:', {
|
||||
targetIndex,
|
||||
adjustedTargetIndex,
|
||||
draggedIndex,
|
||||
topPosition,
|
||||
height: this.draggedBlockHeight,
|
||||
blockMargin,
|
||||
visualBlocks: visualBlocks.map(b => ({ index: b.index, top: b.top, bottom: b.bottom }))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles global drag end
|
||||
*/
|
||||
private handleGlobalDragEnd = (): void => {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('dragover', this.handleGlobalDragOver);
|
||||
document.removeEventListener('dragend', this.handleGlobalDragEnd);
|
||||
|
||||
// Remove drop indicator
|
||||
if (this.dropIndicator) {
|
||||
this.dropIndicator.remove();
|
||||
this.dropIndicator = null;
|
||||
}
|
||||
|
||||
// Trigger the actual drop if we have a dragged block
|
||||
if (this.draggedBlockId) {
|
||||
// Small delay to ensure transforms are applied
|
||||
requestAnimationFrame(() => {
|
||||
this.performDrop();
|
||||
// Call the regular drag end handler after drop
|
||||
this.handleDragEnd();
|
||||
});
|
||||
} else {
|
||||
// Call the regular drag end handler
|
||||
this.handleDragEnd();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs the actual drop operation
|
||||
*/
|
||||
private performDrop(): void {
|
||||
if (!this.draggedBlockId) return;
|
||||
|
||||
// Get the visual order of blocks based on their positions
|
||||
const blockElements = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[];
|
||||
const draggedElement = blockElements.find(el => el.getAttribute('data-block-id') === this.draggedBlockId);
|
||||
|
||||
if (!draggedElement) return;
|
||||
|
||||
|
||||
// Create an array of blocks with their visual positions
|
||||
const visualOrder = blockElements.map(el => {
|
||||
const id = el.getAttribute('data-block-id');
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
return { id, centerY, element: el };
|
||||
});
|
||||
|
||||
// Sort by visual Y position
|
||||
visualOrder.sort((a, b) => a.centerY - b.centerY);
|
||||
|
||||
// Get the new order of block IDs
|
||||
const newBlockIds = visualOrder.map(item => item.id).filter(id => id !== null);
|
||||
|
||||
// Find the original block data
|
||||
const originalBlocks = [...this.component.blocks];
|
||||
const draggedBlock = originalBlocks.find(b => b.id === this.draggedBlockId);
|
||||
|
||||
if (!draggedBlock) return;
|
||||
|
||||
// Check if order actually changed
|
||||
const oldOrder = originalBlocks.map(b => b.id);
|
||||
const orderChanged = !newBlockIds.every((id, index) => id === oldOrder[index]);
|
||||
|
||||
if (!orderChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder blocks based on visual positions
|
||||
const newBlocks = newBlockIds.map(id => originalBlocks.find(b => b.id === id)!).filter(Boolean);
|
||||
|
||||
// Update blocks
|
||||
this.component.blocks = newBlocks;
|
||||
|
||||
// Re-render blocks programmatically
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
|
||||
// Focus the moved block after a delay
|
||||
setTimeout(() => {
|
||||
if (draggedBlock.type !== 'divider') {
|
||||
this.component.blockOperations.focusBlock(draggedBlock.id);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
export interface IFormatButton {
|
||||
command: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles text formatting with smart toggle behavior:
|
||||
* - If selection contains ANY instance of a format, removes ALL instances
|
||||
* - If selection has no formatting, applies the format
|
||||
* - Works correctly with Shadow DOM using range-based operations
|
||||
*/
|
||||
export class WysiwygFormatting {
|
||||
static readonly formatButtons: IFormatButton[] = [
|
||||
{ command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' },
|
||||
{ command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' },
|
||||
{ command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' },
|
||||
{ command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' },
|
||||
{ command: 'code', icon: '{ }', label: 'Inline Code' },
|
||||
{ command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' },
|
||||
];
|
||||
|
||||
static renderFormattingMenu(
|
||||
position: { x: number; y: number },
|
||||
onFormat: (command: string) => void
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="formatting-menu"
|
||||
style="top: ${position.y}px; left: ${position.x}px;"
|
||||
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
|
||||
@click="${(e: MouseEvent) => e.stopPropagation()}"
|
||||
>
|
||||
${this.formatButtons.map(button => html`
|
||||
<button
|
||||
class="format-button ${button.command}"
|
||||
@click="${() => onFormat(button.command)}"
|
||||
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
||||
>
|
||||
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static applyFormat(command: string, value?: string, range?: Range, shadowRoots?: ShadowRoot[]): boolean {
|
||||
// If range is provided, use it directly (Shadow DOM case)
|
||||
// Otherwise fall back to window.getSelection()
|
||||
let workingRange: Range;
|
||||
|
||||
if (range) {
|
||||
workingRange = range;
|
||||
} else {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
workingRange = selection.getRangeAt(0);
|
||||
}
|
||||
|
||||
// Apply format based on command
|
||||
switch (command) {
|
||||
case 'bold':
|
||||
this.wrapSelection(workingRange, 'strong');
|
||||
break;
|
||||
|
||||
case 'italic':
|
||||
this.wrapSelection(workingRange, 'em');
|
||||
break;
|
||||
|
||||
case 'underline':
|
||||
this.wrapSelection(workingRange, 'u');
|
||||
break;
|
||||
|
||||
case 'strikeThrough':
|
||||
this.wrapSelection(workingRange, 's');
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
this.wrapSelection(workingRange, 'code');
|
||||
break;
|
||||
|
||||
case 'link':
|
||||
// Don't use prompt - return false to indicate we need async input
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
this.wrapSelectionWithLink(workingRange, value);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we have shadow roots, use our Shadow DOM selection utility
|
||||
if (shadowRoots && shadowRoots.length > 0) {
|
||||
WysiwygSelection.setSelectionFromRange(workingRange);
|
||||
} else {
|
||||
// Regular selection restoration
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(workingRange);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static wrapSelection(range: Range, tagName: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Check if ANY part of the selection contains this formatting
|
||||
const hasFormatting = this.selectionContainsTag(range, tagName);
|
||||
|
||||
if (hasFormatting) {
|
||||
// Remove all instances of this tag from the selection
|
||||
this.removeTagFromSelection(range, tagName);
|
||||
} else {
|
||||
// Wrap selection with the tag
|
||||
const wrapper = document.createElement(tagName);
|
||||
try {
|
||||
// Extract and wrap contents
|
||||
const contents = range.extractContents();
|
||||
wrapper.appendChild(contents);
|
||||
range.insertNode(wrapper);
|
||||
|
||||
// Select the wrapped content
|
||||
range.selectNodeContents(wrapper);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to wrap selection:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selection contains or is within any instances of a tag
|
||||
*/
|
||||
private static selectionContainsTag(range: Range, tagName: string): boolean {
|
||||
// First check: Are we inside a tag? (even if selection doesn't include the tag)
|
||||
let node: Node | null = range.startContainer;
|
||||
|
||||
while (node && node !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.tagName.toLowerCase() === tagName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
// Also check the end container
|
||||
node = range.endContainer;
|
||||
|
||||
while (node && node !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
if (element.tagName.toLowerCase() === tagName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
// Second check: Does the selection contain any complete tags?
|
||||
const tempDiv = document.createElement('div');
|
||||
const contents = range.cloneContents();
|
||||
tempDiv.appendChild(contents);
|
||||
const tags = tempDiv.getElementsByTagName(tagName);
|
||||
|
||||
return tags.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all instances of a tag from the selection
|
||||
*/
|
||||
private static removeTagFromSelection(range: Range, tagName: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Special handling: Check if we need to expand the selection to include parent tags
|
||||
let expandedRange = range.cloneRange();
|
||||
|
||||
// Check if start is inside a tag
|
||||
let startNode: Node | null = range.startContainer;
|
||||
let startTag: Element | null = null;
|
||||
while (startNode && startNode !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (startNode.nodeType === Node.ELEMENT_NODE && (startNode as Element).tagName.toLowerCase() === tagName) {
|
||||
startTag = startNode as Element;
|
||||
break;
|
||||
}
|
||||
startNode = startNode.parentNode;
|
||||
}
|
||||
|
||||
// Check if end is inside a tag
|
||||
let endNode: Node | null = range.endContainer;
|
||||
let endTag: Element | null = null;
|
||||
while (endNode && endNode !== range.commonAncestorContainer.ownerDocument) {
|
||||
if (endNode.nodeType === Node.ELEMENT_NODE && (endNode as Element).tagName.toLowerCase() === tagName) {
|
||||
endTag = endNode as Element;
|
||||
break;
|
||||
}
|
||||
endNode = endNode.parentNode;
|
||||
}
|
||||
|
||||
// Expand range to include the tags if needed
|
||||
if (startTag) {
|
||||
expandedRange.setStartBefore(startTag);
|
||||
}
|
||||
if (endTag) {
|
||||
expandedRange.setEndAfter(endTag);
|
||||
}
|
||||
|
||||
// Extract the contents using the expanded range
|
||||
const fragment = expandedRange.extractContents();
|
||||
|
||||
// Process the fragment to remove tags
|
||||
const processedFragment = this.removeTagsFromFragment(fragment, tagName);
|
||||
|
||||
// Insert the processed content back
|
||||
expandedRange.insertNode(processedFragment);
|
||||
|
||||
// Restore selection to match the original selection intent
|
||||
// Find the text nodes that correspond to the original selection
|
||||
const textNodes: Node[] = [];
|
||||
const walker = document.createTreeWalker(
|
||||
processedFragment,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
if (textNodes.length > 0) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(textNodes[0], 0);
|
||||
newRange.setEnd(textNodes[textNodes.length - 1], textNodes[textNodes.length - 1].textContent?.length || 0);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all instances of a tag from a document fragment
|
||||
*/
|
||||
private static removeTagsFromFragment(fragment: DocumentFragment, tagName: string): DocumentFragment {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.appendChild(fragment);
|
||||
|
||||
// Find all instances of the tag
|
||||
const tags = tempDiv.getElementsByTagName(tagName);
|
||||
|
||||
// Convert to array to avoid live collection issues
|
||||
const tagArray = Array.from(tags);
|
||||
|
||||
// Unwrap each tag
|
||||
tagArray.forEach(tag => {
|
||||
const parent = tag.parentNode;
|
||||
if (parent) {
|
||||
// Move all children out of the tag
|
||||
while (tag.firstChild) {
|
||||
parent.insertBefore(tag.firstChild, tag);
|
||||
}
|
||||
// Remove the empty tag
|
||||
parent.removeChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new fragment from the processed content
|
||||
const newFragment = document.createDocumentFragment();
|
||||
while (tempDiv.firstChild) {
|
||||
newFragment.appendChild(tempDiv.firstChild);
|
||||
}
|
||||
|
||||
return newFragment;
|
||||
}
|
||||
|
||||
private static wrapSelectionWithLink(range: Range, url: string): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// First remove any existing links in the selection
|
||||
if (this.selectionContainsTag(range, 'a')) {
|
||||
this.removeTagFromSelection(range, 'a');
|
||||
// Re-get the range after modification
|
||||
if (selection.rangeCount > 0) {
|
||||
range = selection.getRangeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
|
||||
try {
|
||||
const contents = range.extractContents();
|
||||
link.appendChild(contents);
|
||||
range.insertNode(link);
|
||||
|
||||
// Select the link
|
||||
range.selectNodeContents(link);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} catch (e) {
|
||||
console.error('Failed to create link:', e);
|
||||
}
|
||||
}
|
||||
|
||||
static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
|
||||
// Get selection info using the new utility that handles Shadow DOM
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
|
||||
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
|
||||
|
||||
if (!selectionInfo) {
|
||||
console.log('No selection info available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a range from the selection info to get bounding rect
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
console.log('Range rect:', rect);
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
console.log('Rect width and height are 0, trying different approach');
|
||||
// Sometimes the rect is collapsed, let's try getting the caret position
|
||||
if ('caretPositionFromPoint' in document) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const tempSpan = document.createElement('span');
|
||||
tempSpan.textContent = '\u200B'; // Zero-width space
|
||||
range.insertNode(tempSpan);
|
||||
const spanRect = tempSpan.getBoundingClientRect();
|
||||
tempSpan.remove();
|
||||
|
||||
if (spanRect.width > 0 || spanRect.height > 0) {
|
||||
const coords = {
|
||||
x: spanRect.left,
|
||||
y: Math.max(45, spanRect.top - 45)
|
||||
};
|
||||
console.log('Used span trick for coords:', coords);
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = {
|
||||
x: rect.left + (rect.width / 2),
|
||||
y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative
|
||||
};
|
||||
|
||||
console.log('Returning coords:', coords);
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
|
||||
export interface IHistoryState {
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
cursorPosition?: {
|
||||
blockId: string;
|
||||
offset: number;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class WysiwygHistory {
|
||||
private history: IHistoryState[] = [];
|
||||
private currentIndex: number = -1;
|
||||
private maxHistorySize: number = 50;
|
||||
private lastSaveTime: number = 0;
|
||||
private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots
|
||||
|
||||
constructor() {
|
||||
// Initialize with empty state
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state to history
|
||||
*/
|
||||
saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Debounce rapid changes (like typing)
|
||||
if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) {
|
||||
// Update the current state instead of creating a new one
|
||||
this.history[this.currentIndex] = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any states after current index (when we save after undoing)
|
||||
if (this.currentIndex < this.history.length - 1) {
|
||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
||||
}
|
||||
|
||||
// Add new state
|
||||
const newState: IHistoryState = {
|
||||
blocks: this.cloneBlocks(blocks),
|
||||
selectedBlockId,
|
||||
cursorPosition: cursorPosition ? { ...cursorPosition } : undefined,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
this.history.push(newState);
|
||||
this.currentIndex++;
|
||||
|
||||
// Limit history size
|
||||
if (this.history.length > this.maxHistorySize) {
|
||||
this.history.shift();
|
||||
this.currentIndex--;
|
||||
}
|
||||
|
||||
this.lastSaveTime = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save a checkpoint (useful for operations like block deletion)
|
||||
*/
|
||||
saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void {
|
||||
this.lastSaveTime = 0; // Reset debounce
|
||||
this.saveState(blocks, selectedBlockId, cursorPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo to previous state
|
||||
*/
|
||||
undo(): IHistoryState | null {
|
||||
if (!this.canUndo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex--;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redo to next state
|
||||
*/
|
||||
redo(): IHistoryState | null {
|
||||
if (!this.canRedo()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if undo is available
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.currentIndex > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if redo is available
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.currentIndex < this.history.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state
|
||||
*/
|
||||
getCurrentState(): IHistoryState | null {
|
||||
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
|
||||
return this.cloneState(this.history[this.currentIndex]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history
|
||||
*/
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
this.lastSaveTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone blocks
|
||||
*/
|
||||
private cloneBlocks(blocks: IBlock[]): IBlock[] {
|
||||
return blocks.map(block => ({
|
||||
...block,
|
||||
metadata: block.metadata ? { ...block.metadata } : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a history state
|
||||
*/
|
||||
private cloneState(state: IHistoryState): IHistoryState {
|
||||
return {
|
||||
blocks: this.cloneBlocks(state.blocks),
|
||||
selectedBlockId: state.selectedBlockId,
|
||||
cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined,
|
||||
timestamp: state.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history info for debugging
|
||||
*/
|
||||
getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } {
|
||||
return {
|
||||
size: this.history.length,
|
||||
currentIndex: this.currentIndex,
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||||
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||||
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
|
||||
|
||||
export class WysiwygInputHandler {
|
||||
private component: IWysiwygComponent;
|
||||
private saveTimeout: any = null;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles input events for blocks
|
||||
*/
|
||||
handleBlockInput(e: InputEvent, block: IBlock): void {
|
||||
if (this.component.isComposing) return;
|
||||
|
||||
const target = e.target as HTMLDivElement;
|
||||
const textContent = target.textContent || '';
|
||||
|
||||
// Check for block type transformations BEFORE updating content
|
||||
const detectedType = this.detectBlockTypeIntent(textContent);
|
||||
if (detectedType && detectedType.type !== block.type) {
|
||||
e.preventDefault();
|
||||
this.handleBlockTransformation(block, detectedType, target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
this.handleSlashCommand(textContent, target);
|
||||
|
||||
// Don't update block content immediately - let the block handle its own content
|
||||
// This prevents re-renders during typing
|
||||
|
||||
// Schedule auto-save (which will sync content later)
|
||||
this.scheduleAutoSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates block content based on its type
|
||||
*/
|
||||
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
||||
// Get the block component for proper content extraction
|
||||
const wrapperElement = target.closest('.block-wrapper');
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent) {
|
||||
// Use the block component's getContent method for consistency
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed to avoid unnecessary updates
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
|
||||
// Update list metadata if needed
|
||||
if (block.type === 'list') {
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback if block component not found
|
||||
if (block.type === 'list') {
|
||||
const listItems = target.querySelectorAll('li');
|
||||
// Use innerHTML to preserve formatting
|
||||
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
||||
|
||||
const listElement = target.querySelector('ol, ul');
|
||||
if (listElement) {
|
||||
block.metadata = {
|
||||
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
||||
};
|
||||
}
|
||||
} else if (block.type === 'code') {
|
||||
block.content = target.textContent || '';
|
||||
} else {
|
||||
block.content = target.innerHTML || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the user is trying to create a specific block type
|
||||
*/
|
||||
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
||||
// Check heading patterns
|
||||
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
||||
if (headingResult) {
|
||||
return headingResult;
|
||||
}
|
||||
|
||||
// Check list patterns
|
||||
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
||||
if (listResult) {
|
||||
return listResult;
|
||||
}
|
||||
|
||||
// Check quote pattern
|
||||
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
||||
return { type: 'quote' };
|
||||
}
|
||||
|
||||
// Check code pattern
|
||||
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
||||
return { type: 'code' };
|
||||
}
|
||||
|
||||
// Check divider pattern
|
||||
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
||||
return { type: 'divider' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles block type transformation
|
||||
*/
|
||||
private async handleBlockTransformation(
|
||||
block: IBlock,
|
||||
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
|
||||
target: HTMLDivElement
|
||||
): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
if (detectedType.type === 'list') {
|
||||
block.type = 'list';
|
||||
block.content = '';
|
||||
block.metadata = { listType: detectedType.listType };
|
||||
|
||||
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
||||
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
WysiwygBlocks.focusListItem(target);
|
||||
}, 0);
|
||||
} else if (detectedType.type === 'divider') {
|
||||
block.type = 'divider';
|
||||
block.content = ' ';
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
const newBlock = blockOps.createBlock();
|
||||
blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
this.component.updateValue();
|
||||
} else if (detectedType.type === 'code') {
|
||||
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.type = 'code';
|
||||
block.content = '';
|
||||
block.metadata = { language };
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
// Focus the code block
|
||||
setTimeout(async () => {
|
||||
await blockOps.focusBlock(block.id, 'start');
|
||||
}, 50);
|
||||
}
|
||||
} else {
|
||||
block.type = detectedType.type;
|
||||
block.content = '';
|
||||
target.textContent = '';
|
||||
|
||||
this.component.updateValue();
|
||||
|
||||
// Update the block element programmatically
|
||||
if (this.component.editorContentRef) {
|
||||
this.component.updateBlockElement(block.id);
|
||||
}
|
||||
|
||||
// Focus the transformed block
|
||||
setTimeout(async () => {
|
||||
await blockOps.focusBlock(block.id, 'start');
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash command detection and menu display
|
||||
*/
|
||||
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
||||
const slashMenu = this.component.slashMenu;
|
||||
const isSlashMenuVisible = slashMenu && slashMenu.visible;
|
||||
|
||||
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
|
||||
if (!isSlashMenuVisible && textContent === '/') {
|
||||
// Get position for menu based on cursor location
|
||||
const rect = this.getCaretCoordinates(target);
|
||||
|
||||
// Show the slash menu at the cursor position
|
||||
slashMenu.show(
|
||||
{ x: rect.left, y: rect.bottom + 4 },
|
||||
(type: string) => {
|
||||
this.component.insertBlock(type);
|
||||
}
|
||||
);
|
||||
|
||||
// Ensure the block maintains focus
|
||||
requestAnimationFrame(() => {
|
||||
if (document.activeElement !== target) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Update filter
|
||||
if (slashMenu) {
|
||||
slashMenu.updateFilter(textContent.slice(1));
|
||||
}
|
||||
} else if (!textContent.startsWith('/')) {
|
||||
this.component.closeSlashMenu();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the coordinates of the caret/cursor
|
||||
*/
|
||||
private getCaretCoordinates(element: HTMLElement): DOMRect {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to element position
|
||||
return element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules auto-save after a delay
|
||||
*/
|
||||
private scheduleAutoSave(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
// Don't auto-save if slash menu is open
|
||||
if (this.component.slashMenu && this.component.slashMenu.visible) {
|
||||
return;
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
// Sync all block content from DOM before saving
|
||||
this.syncAllBlockContent();
|
||||
// Only update value, don't trigger any re-renders
|
||||
this.component.updateValue();
|
||||
// Don't call requestUpdate() as it's not needed
|
||||
}, 2000); // Increased delay to reduce interference with typing
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs content from all block DOMs to the data model
|
||||
*/
|
||||
private syncAllBlockContent(): void {
|
||||
this.component.blocks.forEach((block: IBlock) => {
|
||||
const wrapperElement = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getContent) {
|
||||
const newContent = blockComponent.getContent();
|
||||
// Only update if content actually changed
|
||||
if (block.content !== newContent) {
|
||||
block.content = newContent;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type TemplateResult } from '@design.estate/dees-element';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { DeesSlashMenu } from './dees-slash-menu.js';
|
||||
import { DeesFormattingMenu } from './dees-formatting-menu.js';
|
||||
|
||||
/**
|
||||
* Interface for the main wysiwyg component
|
||||
*/
|
||||
export interface IWysiwygComponent {
|
||||
// State
|
||||
blocks: IBlock[];
|
||||
selectedBlockId: string | null;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
editorContentRef: HTMLDivElement;
|
||||
draggedBlockId: string | null;
|
||||
dragOverBlockId: string | null;
|
||||
dragOverPosition: 'before' | 'after' | null;
|
||||
isComposing: boolean;
|
||||
|
||||
// Menus
|
||||
slashMenu: DeesSlashMenu;
|
||||
formattingMenu: DeesFormattingMenu;
|
||||
|
||||
// Methods
|
||||
updateValue(): void;
|
||||
requestUpdate(): void;
|
||||
updateComplete: Promise<boolean>;
|
||||
insertBlock(type: string): Promise<void>;
|
||||
closeSlashMenu(clearSlash?: boolean): void;
|
||||
applyFormat(command: string): Promise<void>;
|
||||
handleSlashMenuKeyboard(e: KeyboardEvent): void;
|
||||
createBlockElement(block: IBlock): HTMLElement;
|
||||
updateBlockElement(blockId: string): void;
|
||||
handleDrop(e: DragEvent, targetBlock: IBlock): void;
|
||||
renderBlocksProgrammatically(): void;
|
||||
saveToHistory(debounce?: boolean): void;
|
||||
|
||||
// Handlers
|
||||
blockOperations: IBlockOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block operations
|
||||
*/
|
||||
export interface IBlockOperations {
|
||||
createBlock(type?: IBlock['type'], content?: string, metadata?: any): IBlock;
|
||||
insertBlockAfter(afterBlock: IBlock, newBlock: IBlock, focusNewBlock?: boolean): Promise<void>;
|
||||
removeBlock(blockId: string): void;
|
||||
findBlock(blockId: string): IBlock | undefined;
|
||||
getBlockIndex(blockId: string): number;
|
||||
focusBlock(blockId: string, cursorPosition?: 'start' | 'end' | number): Promise<void>;
|
||||
updateBlockContent(blockId: string, content: string): void;
|
||||
transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void;
|
||||
moveBlock(blockId: string, targetIndex: number): void;
|
||||
getPreviousBlock(blockId: string): IBlock | null;
|
||||
getNextBlock(blockId: string): IBlock | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for block component
|
||||
*/
|
||||
export interface IWysiwygBlockComponent {
|
||||
block: IBlock;
|
||||
isSelected: boolean;
|
||||
blockElement: HTMLDivElement | null;
|
||||
|
||||
focus(): void;
|
||||
focusWithCursor(position: 'start' | 'end' | number): void;
|
||||
getContent(): string;
|
||||
setContent(content: string): void;
|
||||
setCursorToStart(): void;
|
||||
setCursorToEnd(): void;
|
||||
focusListItem(): void;
|
||||
getSplitContent(splitPosition: number): { before: string; after: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler interfaces
|
||||
*/
|
||||
export interface IBlockEventHandlers {
|
||||
onInput: (e: InputEvent) => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onMouseUp?: (e: MouseEvent) => void;
|
||||
onRequestUpdate?: () => void; // Request immediate re-render of the block
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
||||
import { WysiwygSelection } from './wysiwyg.selection.js';
|
||||
|
||||
export class WysiwygKeyboardHandler {
|
||||
private component: IWysiwygComponent;
|
||||
|
||||
constructor(component: IWysiwygComponent) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard events for blocks
|
||||
*/
|
||||
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// Handle slash menu navigation
|
||||
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
|
||||
this.component.handleSlashMenuKeyboard(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle formatting shortcuts
|
||||
if (this.handleFormattingShortcuts(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
switch (e.key) {
|
||||
case 'Tab':
|
||||
this.handleTab(e, block);
|
||||
break;
|
||||
case 'Enter':
|
||||
await this.handleEnter(e, block);
|
||||
break;
|
||||
case 'Backspace':
|
||||
await this.handleBackspace(e, block);
|
||||
break;
|
||||
case 'Delete':
|
||||
await this.handleDelete(e, block);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
await this.handleArrowUp(e, block);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
await this.handleArrowDown(e, block);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
await this.handleArrowLeft(e, block);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
await this.handleArrowRight(e, block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if key is for slash menu navigation
|
||||
*/
|
||||
private isSlashMenuKey(key: string): boolean {
|
||||
return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles formatting keyboard shortcuts
|
||||
*/
|
||||
private handleFormattingShortcuts(e: KeyboardEvent): boolean {
|
||||
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'b':
|
||||
e.preventDefault();
|
||||
// Use Promise to ensure focus is maintained
|
||||
Promise.resolve().then(() => this.component.applyFormat('bold'));
|
||||
return true;
|
||||
case 'i':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('italic'));
|
||||
return true;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('underline'));
|
||||
return true;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
Promise.resolve().then(() => this.component.applyFormat('link'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Tab key
|
||||
*/
|
||||
private handleTab(e: KeyboardEvent, block: IBlock): void {
|
||||
if (block.type === 'code') {
|
||||
// Allow tab in code blocks - handled by CodeBlockHandler
|
||||
// Let it bubble to the block handler
|
||||
return;
|
||||
} else if (block.type === 'list') {
|
||||
// Future: implement list indentation
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key
|
||||
*/
|
||||
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// For non-editable blocks, create a new paragraph after
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === 'code') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter in code blocks creates a new block
|
||||
e.preventDefault();
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Normal Enter in code blocks creates new line (let browser handle it)
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.shiftKey) {
|
||||
if (block.type === 'list') {
|
||||
await this.handleEnterInList(e, block);
|
||||
} else {
|
||||
// Split content at cursor position
|
||||
e.preventDefault();
|
||||
|
||||
// Get the block component - need to search in the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getSplitContent) {
|
||||
const splitContent = blockComponent.getSplitContent();
|
||||
|
||||
if (splitContent) {
|
||||
// Update current block with content before cursor
|
||||
blockComponent.setContent(splitContent.before);
|
||||
block.content = splitContent.before;
|
||||
|
||||
// Create new block with content after cursor
|
||||
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
||||
|
||||
// Insert the new block
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
// Update the value after both blocks are set
|
||||
this.component.updateValue();
|
||||
} else {
|
||||
// Fallback - just create empty block
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
} else {
|
||||
// No block component or method, just create empty block
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shift+Enter creates line break (let browser handle it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Enter key in list blocks
|
||||
*/
|
||||
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const currentLi = range.startContainer.parentElement?.closest('li');
|
||||
|
||||
if (currentLi && currentLi.textContent === '') {
|
||||
// Empty list item - exit list mode
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
// Otherwise, let browser create new list item
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Backspace key
|
||||
*/
|
||||
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// Handle non-editable blocks
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
|
||||
// If it's the only block, delete it and create a new paragraph
|
||||
if (this.component.blocks.length === 1) {
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Create a new paragraph block
|
||||
const newBlock = blockOps.createBlock('paragraph', '');
|
||||
this.component.blocks = [newBlock];
|
||||
|
||||
// Re-render blocks
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Focus the new block
|
||||
await blockOps.focusBlock(newBlock.id, 'start');
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Find the previous block to focus
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus the appropriate block
|
||||
if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
} else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') {
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
} else if (prevBlock) {
|
||||
// If previous block is also non-editable, just select it
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
} else if (nextBlock) {
|
||||
// If next block is also non-editable, just select it
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component to check cursor position
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get cursor position
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
|
||||
const actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
if (cursorPos === 0) {
|
||||
e.preventDefault();
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
// If previous block is non-editable, select it first
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(prevBlock.type)) {
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Save checkpoint for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Special handling for different block types
|
||||
if (prevBlock.type === 'code' && block.type !== 'code') {
|
||||
// Can't merge non-code into code block, just remove empty block
|
||||
if (block.content === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.type === 'code' && prevBlock.type !== 'code') {
|
||||
// Can't merge code into non-code block
|
||||
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
if (actualContent === '' || actualContent.trim() === '') {
|
||||
blockOps.removeBlock(block.id);
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the content of both blocks
|
||||
const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`);
|
||||
const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
const prevContent = prevBlockComponent?.getContent() || prevBlock.content || '';
|
||||
const currentContent = blockComponent.getContent() || block.content || '';
|
||||
|
||||
// Merge content
|
||||
let mergedContent = '';
|
||||
if (prevBlock.type === 'code' && block.type === 'code') {
|
||||
// For code blocks, join with newline
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else if (prevBlock.type === 'list' && block.type === 'list') {
|
||||
// For lists, combine the list items
|
||||
mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent;
|
||||
} else {
|
||||
// For other blocks, join with space if both have content
|
||||
mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent;
|
||||
}
|
||||
|
||||
// Store cursor position (where the merge point is)
|
||||
const mergePoint = prevContent.length;
|
||||
|
||||
// Update previous block with merged content
|
||||
blockOps.updateBlockContent(prevBlock.id, mergedContent);
|
||||
if (prevBlockComponent) {
|
||||
prevBlockComponent.setContent(mergedContent);
|
||||
}
|
||||
|
||||
// Remove current block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus previous block at merge point
|
||||
await blockOps.focusBlock(prevBlock.id, mergePoint);
|
||||
}
|
||||
} else if (this.component.blocks.length > 1) {
|
||||
// Check if block is actually empty by getting current content from DOM
|
||||
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
|
||||
if (currentContent === '' || currentContent.trim() === '') {
|
||||
// Empty block - just remove it
|
||||
e.preventDefault();
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
if (prevBlock.type !== 'divider') {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal backspace
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Delete key
|
||||
*/
|
||||
private async handleDelete(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
const blockOps = this.component.blockOperations;
|
||||
|
||||
// Handle non-editable blocks - same as backspace
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
|
||||
// If it's the only block, delete it and create a new paragraph
|
||||
if (this.component.blocks.length === 1) {
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Create a new paragraph block
|
||||
const newBlock = blockOps.createBlock('paragraph', '');
|
||||
this.component.blocks = [newBlock];
|
||||
|
||||
// Re-render blocks
|
||||
this.component.renderBlocksProgrammatically();
|
||||
|
||||
// Focus the new block
|
||||
await blockOps.focusBlock(newBlock.id, 'start');
|
||||
|
||||
// Update value
|
||||
this.component.updateValue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Save state for undo
|
||||
this.component.saveToHistory(false);
|
||||
|
||||
// Find the previous block to focus
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
// Remove the block
|
||||
blockOps.removeBlock(block.id);
|
||||
|
||||
// Focus the appropriate block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) {
|
||||
await blockOps.focusBlock(nextBlock.id, 'start');
|
||||
} else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) {
|
||||
await blockOps.focusBlock(prevBlock.id, 'end');
|
||||
} else if (nextBlock) {
|
||||
// If next block is also non-editable, just select it
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
} else if (prevBlock) {
|
||||
// If previous block is also non-editable, just select it
|
||||
await blockOps.focusBlock(prevBlock.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For editable blocks, check if we're at the end and next block is non-editable
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get cursor position
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
const textLength = target.textContent?.length || 0;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
if (cursorPos === textLength) {
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nextBlock && nonEditableTypes.includes(nextBlock.type)) {
|
||||
e.preventDefault();
|
||||
await blockOps.focusBlock(nextBlock.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, let browser handle normal delete
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowUp key - navigate to previous block if at beginning or first line
|
||||
*/
|
||||
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, always navigate to previous block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element - code blocks now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if we're on the first line
|
||||
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowDown key - navigate to next block if at end or last line
|
||||
*/
|
||||
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, always navigate to next block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element - code blocks now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if we're on the last line
|
||||
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let browser handle normal navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the last text node in an element
|
||||
*/
|
||||
private getLastTextNode(element: Node): Text | null {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
return element as Text;
|
||||
}
|
||||
|
||||
for (let i = element.childNodes.length - 1; i >= 0; i--) {
|
||||
const lastText = this.getLastTextNode(element.childNodes[i]);
|
||||
if (lastText) return lastText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowLeft key - navigate to previous block if at beginning
|
||||
*/
|
||||
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, navigate to previous block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element - code blocks now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if cursor is at the beginning of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
|
||||
if (cursorPos === 0) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const prevBlock = blockOps.getPreviousBlock(block.id);
|
||||
|
||||
if (prevBlock) {
|
||||
e.preventDefault();
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
|
||||
await blockOps.focusBlock(prevBlock.id, position);
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal left arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles ArrowRight key - navigate to next block if at end
|
||||
*/
|
||||
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
|
||||
// For non-editable blocks, navigate to next block
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
e.preventDefault();
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the block component from the wysiwyg component's shadow DOM
|
||||
const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
|
||||
if (!blockComponent || !blockComponent.shadowRoot) return;
|
||||
|
||||
// Get the actual editable element - code blocks now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') as HTMLElement
|
||||
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
// Get selection info with proper shadow DOM support
|
||||
const parentComponent = blockComponent.closest('dees-input-wysiwyg');
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
|
||||
shadowRoots.push(blockComponent.shadowRoot);
|
||||
|
||||
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return;
|
||||
|
||||
// Check if cursor is at the end of the block
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
const textLength = target.textContent?.length || 0;
|
||||
|
||||
if (cursorPos === textLength) {
|
||||
const blockOps = this.component.blockOperations;
|
||||
const nextBlock = blockOps.getNextBlock(block.id);
|
||||
|
||||
if (nextBlock) {
|
||||
e.preventDefault();
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
||||
}
|
||||
}
|
||||
// Otherwise, let the browser handle normal right arrow navigation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles slash menu keyboard navigation
|
||||
* Note: This is now handled by the component directly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if cursor is on the first line of a block
|
||||
*/
|
||||
private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the top position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the top (within 5px tolerance for line height variations)
|
||||
const isNearTop = rect.top - containerRect.top < 5;
|
||||
|
||||
// For single-line content, also check if we're at the beginning
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots);
|
||||
return cursorPos === 0;
|
||||
}
|
||||
|
||||
return isNearTop;
|
||||
} catch (e) {
|
||||
console.warn('Error checking first line:', e);
|
||||
// Fallback to position-based check
|
||||
const cursorPos = selectionInfo.startOffset;
|
||||
return cursorPos === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cursor is on the last line of a block
|
||||
*/
|
||||
private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean {
|
||||
try {
|
||||
// Create a range from the selection info
|
||||
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Get the container element
|
||||
let container = range.commonAncestorContainer;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
|
||||
// Get the bottom position of the container
|
||||
const containerRect = (container as Element).getBoundingClientRect();
|
||||
|
||||
// Check if we're near the bottom (within 5px tolerance for line height variations)
|
||||
const isNearBottom = containerRect.bottom - rect.bottom < 5;
|
||||
|
||||
// For single-line content, also check if we're at the end
|
||||
if (container.textContent && !container.textContent.includes('\n')) {
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
|
||||
return isNearBottom;
|
||||
} catch (e) {
|
||||
console.warn('Error checking last line:', e);
|
||||
// Fallback to position-based check
|
||||
const textLength = target.textContent?.length || 0;
|
||||
const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
|
||||
return cursorPos === textLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { html, type TemplateResult, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesModal } from '../../dees-modal/dees-modal.js';
|
||||
import { type IBlock } from './wysiwyg.types.js';
|
||||
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
||||
import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js';
|
||||
|
||||
export class WysiwygModalManager {
|
||||
/**
|
||||
* Shows language selection modal for code blocks
|
||||
*/
|
||||
static async showLanguageSelectionModal(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let selectedLanguage: string | null = null;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Select Programming Language',
|
||||
content: html`
|
||||
<style>
|
||||
.language-container {
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 12px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.language-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.language-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
</style>
|
||||
<div class="language-container">
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div
|
||||
class="language-button ${selectedLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${() => {
|
||||
selectedLanguage = lang.toLowerCase();
|
||||
// Close modal by finding it in DOM
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
resolve(selectedLanguage);
|
||||
}}">
|
||||
${lang}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows block settings modal
|
||||
*/
|
||||
static async showBlockSettingsModal(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): Promise<void> {
|
||||
|
||||
const content = html`
|
||||
<style>
|
||||
.settings-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.settings-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.block-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.block-type-button {
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.block-type-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.block-type-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
.block-type-icon {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<div class="settings-container">
|
||||
${this.getBlockTypeSelector(block, onUpdate)}
|
||||
${block.type === 'code' ? this.getCodeBlockSettings(block, onUpdate) : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Block Settings',
|
||||
content,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Done',
|
||||
action: async (modal) => {
|
||||
modal.destroy();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets code block settings content
|
||||
*/
|
||||
private static getCodeBlockSettings(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const currentLanguage = block.metadata?.language || 'javascript';
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.language-button {
|
||||
padding: 8px 4px;
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
|
||||
}
|
||||
.language-button:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#1f2937')};
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
|
||||
}
|
||||
.language-button.selected {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
color: ${cssManager.bdTheme('#111827', '#f9fafb')};
|
||||
}
|
||||
</style>
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Programming Language</div>
|
||||
<div class="language-grid">
|
||||
${this.getLanguages().map(lang => html`
|
||||
<div
|
||||
class="language-button ${currentLanguage === lang.toLowerCase() ? 'selected' : ''}"
|
||||
@click="${() => {
|
||||
if (!block.metadata) block.metadata = {};
|
||||
block.metadata.language = lang.toLowerCase();
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal immediately
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
}}"
|
||||
data-lang="${lang}"
|
||||
>${lang}</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available programming languages
|
||||
*/
|
||||
private static getLanguages(): string[] {
|
||||
return [...PROGRAMMING_LANGUAGES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block type selector
|
||||
*/
|
||||
private static getBlockTypeSelector(
|
||||
block: IBlock,
|
||||
onUpdate: (block: IBlock) => void
|
||||
): TemplateResult {
|
||||
const blockTypes = WysiwygShortcuts.getSlashMenuItems().filter(item => item.type !== 'divider');
|
||||
|
||||
return html`
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Block Type</div>
|
||||
<div class="block-type-grid">
|
||||
${blockTypes.map(item => html`
|
||||
<div
|
||||
class="block-type-button ${block.type === item.type ? 'selected' : ''}"
|
||||
@click="${async (e: MouseEvent) => {
|
||||
const button = e.currentTarget as HTMLElement;
|
||||
|
||||
const oldType = block.type;
|
||||
block.type = item.type as IBlock['type'];
|
||||
|
||||
// Reset metadata for type change
|
||||
if (oldType === 'code' && block.type !== 'code') {
|
||||
delete block.metadata?.language;
|
||||
} else if (oldType === 'list' && block.type !== 'list') {
|
||||
delete block.metadata?.listType;
|
||||
} else if (block.type === 'list' && !block.metadata?.listType) {
|
||||
block.metadata = { listType: 'bullet' };
|
||||
} else if (block.type === 'code' && !block.metadata?.language) {
|
||||
// Ask for language if changing to code block
|
||||
const language = await this.showLanguageSelectionModal();
|
||||
if (language) {
|
||||
block.metadata = { language };
|
||||
} else {
|
||||
// User cancelled, revert
|
||||
block.type = oldType;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(block);
|
||||
|
||||
// Close modal immediately
|
||||
const modal = document.querySelector('dees-modal');
|
||||
if (modal && typeof (modal as any).destroy === 'function') {
|
||||
(modal as any).destroy();
|
||||
}
|
||||
}}"
|
||||
>
|
||||
<span class="block-type-icon">${item.icon}</span>
|
||||
<span>${item.label}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Utilities for handling selection across Shadow DOM boundaries
|
||||
*/
|
||||
|
||||
export interface SelectionInfo {
|
||||
startContainer: Node;
|
||||
startOffset: number;
|
||||
endContainer: Node;
|
||||
endOffset: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// Type for the extended caretPositionFromPoint with Shadow DOM support
|
||||
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
|
||||
|
||||
export class WysiwygSelection {
|
||||
/**
|
||||
* Gets selection info that works across Shadow DOM boundaries
|
||||
* @param shadowRoots - Shadow roots to include in the selection search
|
||||
*/
|
||||
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
|
||||
const selection = window.getSelection();
|
||||
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
|
||||
if (!selection) return null;
|
||||
|
||||
// Try using getComposedRanges if available (better Shadow DOM support)
|
||||
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
|
||||
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
|
||||
try {
|
||||
// Pass shadow roots in the correct format as per MDN
|
||||
const ranges = selection.getComposedRanges({ shadowRoots });
|
||||
console.log('getComposedRanges returned', ranges.length, 'ranges');
|
||||
if (ranges.length > 0) {
|
||||
const range = ranges[0];
|
||||
return {
|
||||
startContainer: range.startContainer,
|
||||
startOffset: range.startOffset,
|
||||
endContainer: range.endContainer,
|
||||
endOffset: range.endOffset,
|
||||
collapsed: range.collapsed
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('getComposedRanges not available, using fallback');
|
||||
}
|
||||
|
||||
// Fallback to traditional selection API
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
return {
|
||||
startContainer: range.startContainer,
|
||||
startOffset: range.startOffset,
|
||||
endContainer: range.endContainer,
|
||||
endOffset: range.endOffset,
|
||||
collapsed: range.collapsed
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a selection is within a specific element (considering Shadow DOM)
|
||||
*/
|
||||
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
|
||||
const selectionInfo = shadowRoot
|
||||
? this.getSelectionInfo(shadowRoot)
|
||||
: this.getSelectionInfo();
|
||||
|
||||
if (!selectionInfo) return false;
|
||||
|
||||
// Check if the selection's common ancestor is within the element
|
||||
return element.contains(selectionInfo.startContainer) ||
|
||||
element.contains(selectionInfo.endContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the selected text across Shadow DOM boundaries
|
||||
*/
|
||||
static getSelectedText(): string {
|
||||
const selection = window.getSelection();
|
||||
return selection ? selection.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a range from selection info
|
||||
*/
|
||||
static createRangeFromInfo(info: SelectionInfo): Range {
|
||||
const range = document.createRange();
|
||||
range.setStart(info.startContainer, info.startOffset);
|
||||
range.setEnd(info.endContainer, info.endOffset);
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets selection from a range (works with Shadow DOM)
|
||||
*/
|
||||
static setSelectionFromRange(range: Range): void {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cursor position relative to a specific element
|
||||
*/
|
||||
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
|
||||
const selectionInfo = shadowRoots.length > 0
|
||||
? this.getSelectionInfo(...shadowRoots)
|
||||
: this.getSelectionInfo();
|
||||
|
||||
if (!selectionInfo || !selectionInfo.collapsed) return null;
|
||||
|
||||
// Create a range from start of element to cursor position
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
|
||||
// Handle case where selection is in a text node that's a child of the element
|
||||
// Use our Shadow DOM-aware contains method
|
||||
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
|
||||
|
||||
if (isContained) {
|
||||
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
const position = range.toString().length;
|
||||
return position;
|
||||
} else {
|
||||
// Selection might be in shadow DOM or different context
|
||||
// Try to find the equivalent position in the element
|
||||
const text = element.textContent || '';
|
||||
const selectionText = selectionInfo.startContainer.textContent || '';
|
||||
|
||||
// If the selection is at the beginning or end, handle those cases
|
||||
if (selectionInfo.startOffset === 0) {
|
||||
return 0;
|
||||
} else if (selectionInfo.startOffset === selectionText.length) {
|
||||
return text.length;
|
||||
}
|
||||
|
||||
// For other cases, try to match based on text content
|
||||
console.warn('Selection container not within element, using text matching fallback');
|
||||
return selectionInfo.startOffset;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get cursor position:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets cursor position from mouse coordinates with Shadow DOM support
|
||||
*/
|
||||
static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null {
|
||||
// Try modern API with shadow root support
|
||||
if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) {
|
||||
let caretPos: CaretPosition | null = null;
|
||||
|
||||
// Try with shadow roots first (newer API)
|
||||
try {
|
||||
caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots);
|
||||
} catch (e) {
|
||||
// Fallback to standard API without shadow roots
|
||||
caretPos = document.caretPositionFromPoint(x, y);
|
||||
}
|
||||
|
||||
if (caretPos && container.contains(caretPos.offsetNode)) {
|
||||
// Calculate total offset within the container
|
||||
return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container);
|
||||
}
|
||||
}
|
||||
|
||||
// Safari/WebKit fallback
|
||||
if ('caretRangeFromPoint' in document) {
|
||||
const range = (document as any).caretRangeFromPoint(x, y);
|
||||
if (range && container.contains(range.startContainer)) {
|
||||
return this.getOffsetInElement(range.startContainer, range.startOffset, container);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the total character offset of a position within an element
|
||||
*/
|
||||
private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number {
|
||||
let totalOffset = 0;
|
||||
let found = false;
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let textNode: Node | null;
|
||||
while (textNode = walker.nextNode()) {
|
||||
if (textNode === node) {
|
||||
totalOffset += offset;
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
totalOffset += textNode.textContent?.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return found ? totalOffset : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets cursor position in an element
|
||||
*/
|
||||
static setCursorPosition(element: Element, position: number): void {
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let currentPosition = 0;
|
||||
let targetNode: Text | null = null;
|
||||
let targetOffset = 0;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode as Text;
|
||||
const nodeLength = node.textContent?.length || 0;
|
||||
|
||||
if (currentPosition + nodeLength >= position) {
|
||||
targetNode = node;
|
||||
targetOffset = position - currentPosition;
|
||||
break;
|
||||
}
|
||||
|
||||
currentPosition += nodeLength;
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
const range = document.createRange();
|
||||
range.setStart(targetNode, targetOffset);
|
||||
range.collapse(true);
|
||||
this.setSelectionFromRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is contained within an element across Shadow DOM boundaries
|
||||
* This is needed because element.contains() doesn't work across Shadow DOM
|
||||
*/
|
||||
static containsAcrossShadowDOM(container: Node, node: Node): boolean {
|
||||
if (!container || !node) return false;
|
||||
|
||||
// Start with the node and traverse up
|
||||
let current: Node | null = node;
|
||||
|
||||
while (current) {
|
||||
// Direct match
|
||||
if (current === container) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we're at a shadow root, check its host
|
||||
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (current as any).host) {
|
||||
const shadowRoot = current as ShadowRoot;
|
||||
// Check if the container is within this shadow root
|
||||
if (shadowRoot.contains(container)) {
|
||||
return false; // Container is in a child shadow DOM
|
||||
}
|
||||
// Move to the host element
|
||||
current = shadowRoot.host;
|
||||
} else {
|
||||
// Regular DOM traversal
|
||||
current = current.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type IBlock, type IShortcutPattern, type ISlashMenuItem } from './wysiwyg.types.js';
|
||||
|
||||
export class WysiwygShortcuts {
|
||||
static readonly HEADING_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^#[\s\u00A0]$/, type: 'heading-1' },
|
||||
{ pattern: /^##[\s\u00A0]$/, type: 'heading-2' },
|
||||
{ pattern: /^###[\s\u00A0]$/, type: 'heading-3' }
|
||||
];
|
||||
|
||||
static readonly LIST_PATTERNS: IShortcutPattern[] = [
|
||||
{ pattern: /^[*-][\s\u00A0]$/, type: 'bullet' },
|
||||
{ pattern: /^(\d+)\.[\s\u00A0]$/, type: 'ordered' },
|
||||
{ pattern: /^(\d+)\)[\s\u00A0]$/, type: 'ordered' }
|
||||
];
|
||||
|
||||
static readonly QUOTE_PATTERN = /^>[\s\u00A0]$/;
|
||||
static readonly CODE_PATTERN = /^```$/;
|
||||
static readonly DIVIDER_PATTERNS = ['---', '***', '___'];
|
||||
|
||||
static checkHeadingShortcut(content: string): { type: IBlock['type'] } | null {
|
||||
for (const { pattern, type } of this.HEADING_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: type as IBlock['type'] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkListShortcut(content: string): { type: 'list', listType: 'bullet' | 'ordered' } | null {
|
||||
for (const { pattern, type } of this.LIST_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return { type: 'list', listType: type as 'bullet' | 'ordered' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static checkQuoteShortcut(content: string): boolean {
|
||||
return this.QUOTE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkCodeShortcut(content: string): boolean {
|
||||
return this.CODE_PATTERN.test(content);
|
||||
}
|
||||
|
||||
static checkDividerShortcut(content: string): boolean {
|
||||
return this.DIVIDER_PATTERNS.includes(content);
|
||||
}
|
||||
|
||||
static getSlashMenuItems(): ISlashMenuItem[] {
|
||||
return [
|
||||
{ type: 'paragraph', label: 'Paragraph', icon: 'lucide:pilcrow' },
|
||||
{ type: 'heading-1', label: 'Heading 1', icon: 'lucide:heading1' },
|
||||
{ type: 'heading-2', label: 'Heading 2', icon: 'lucide:heading2' },
|
||||
{ type: 'heading-3', label: 'Heading 3', icon: 'lucide:heading3' },
|
||||
{ type: 'quote', label: 'Quote', icon: 'lucide:quote' },
|
||||
{ type: 'code', label: 'Code Block', icon: 'lucide:fileCode' },
|
||||
{ type: 'list', label: 'Bullet List', icon: 'lucide:list' },
|
||||
{ type: 'image', label: 'Image', icon: 'lucide:image' },
|
||||
{ type: 'divider', label: 'Divider', icon: 'lucide:minus' },
|
||||
{ type: 'youtube', label: 'YouTube', icon: 'lucide:youtube' },
|
||||
{ type: 'markdown', label: 'Markdown', icon: 'lucide:fileText' },
|
||||
{ type: 'html', label: 'HTML', icon: 'lucide:code' },
|
||||
{ type: 'attachment', label: 'File Attachment', icon: 'lucide:paperclip' },
|
||||
];
|
||||
}
|
||||
|
||||
static generateBlockId(): string {
|
||||
return `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the type that is used in this module
|
||||
export type { ISlashMenuItem } from './wysiwyg.types.js';
|
||||
@@ -0,0 +1,549 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const wysiwygStyles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wysiwyg-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 6px;
|
||||
min-height: 200px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:hover {
|
||||
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
|
||||
}
|
||||
|
||||
.wysiwyg-container:focus-within {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#f4f4f5', '#18181b')}, 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.5)', 'rgba(59, 130, 246, 0.5)')};
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
/* Visual hint for text selection */
|
||||
.editor-content:hover {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
outline: none;
|
||||
min-height: 160px;
|
||||
margin: 0 -8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
min-height: 1.6em;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
/* First and last blocks don't need extra spacing */
|
||||
.block-wrapper:first-child .block {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.block-wrapper:last-child .block {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block.selected {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.05)', 'rgba(59, 130, 246, 0.05)')};
|
||||
outline: 2px solid ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.block[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.block.paragraph {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.paragraph:empty::before {
|
||||
content: "Type '/' for commands...";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.heading-1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.block.heading-1:empty::before {
|
||||
content: "Heading 1";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.block.heading-2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.block.heading-2:empty::before {
|
||||
content: "Heading 2";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.heading-3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.block.heading-3:empty::before {
|
||||
content: "Heading 3";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.block.quote {
|
||||
border-left: 2px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
padding-left: 20px;
|
||||
font-style: italic;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.quote:empty::before {
|
||||
content: "Quote";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-block-container {
|
||||
position: relative;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 0 4px 0 4px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
text-transform: lowercase;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.block.code {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
padding-top: 32px; /* Make room for language indicator */
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.block.code:empty::before {
|
||||
content: "// Code block";
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
pointer-events: none;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.block.list {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.block.list ul,
|
||||
.block.list ol {
|
||||
margin: 0;
|
||||
padding: 0 0 0 24px;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.block.list ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.block.list ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.block.list li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.block.list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.block.divider {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.block.divider hr {
|
||||
border: none;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slash-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
padding: 4px;
|
||||
z-index: 1000;
|
||||
min-width: 220px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slash-menu-item {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-radius: 3px;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover,
|
||||
.slash-menu-item.selected {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.slash-menu-item .icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slash-menu-item:hover .icon,
|
||||
.slash-menu-item.selected .icon {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
padding: 4px;
|
||||
display: none;
|
||||
gap: 4px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toolbar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
/* Drag and Drop Styles */
|
||||
.block-wrapper {
|
||||
position: relative;
|
||||
transition: transform 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing context for blocks */
|
||||
.block-wrapper + .block-wrapper .block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Override for headings following other blocks */
|
||||
.block-wrapper + .block-wrapper .block.heading-1,
|
||||
.block-wrapper + .block-wrapper .block.heading-2,
|
||||
.block-wrapper + .block-wrapper .block.heading-3 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Code and quote blocks need consistent spacing */
|
||||
.block-wrapper + .block-wrapper .block.code,
|
||||
.block-wrapper + .block-wrapper .block.quote {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.drag-handle::before {
|
||||
content: "⋮⋮";
|
||||
font-size: 12px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.block-wrapper:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
}
|
||||
|
||||
.block-wrapper.dragging {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
z-index: 2001;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Blocks that should move out of the way */
|
||||
.block-wrapper.move-down {
|
||||
transform: translateY(var(--drag-offset, 0px));
|
||||
}
|
||||
|
||||
.block-wrapper.move-up {
|
||||
transform: translateY(calc(-1 * var(--drag-offset, 0px)));
|
||||
}
|
||||
|
||||
/* Drop indicator */
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.05)', 'rgba(59, 130, 246, 0.05)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
border-radius: 4px;
|
||||
transition: top 0.2s ease, height 0.2s ease;
|
||||
pointer-events: none;
|
||||
z-index: 1999;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Remove old drag-over styles */
|
||||
.block-wrapper.drag-over-before,
|
||||
.block-wrapper.drag-over-after {
|
||||
/* No longer needed, using drop indicator instead */
|
||||
}
|
||||
|
||||
.editor-content.dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Block Settings Button - Removed in favor of context menu */
|
||||
|
||||
/* Text Selection Styles */
|
||||
.block ::selection {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Formatting Menu */
|
||||
.formatting-menu {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 1001;
|
||||
animation: fadeInScale 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98) translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.format-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.format-button:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.format-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.format-button.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.format-button.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.format-button.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.format-button .code-icon {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Applied format styles in content */
|
||||
.block strong,
|
||||
.block b {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.block em,
|
||||
.block i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.block u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.block strike,
|
||||
.block s {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block code {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#27272a')};
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.block a {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.block a:hover {
|
||||
border-bottom-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface IBlock {
|
||||
id: string;
|
||||
type: 'paragraph' | 'heading-1' | 'heading-2' | 'heading-3' | 'image' | 'code' | 'quote' | 'list' | 'divider' | 'youtube' | 'markdown' | 'html' | 'attachment';
|
||||
content: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ISlashMenuItem {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
export interface IShortcutPattern {
|
||||
pattern: RegExp;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type OutputFormat = 'html' | 'markdown';
|
||||
19
ts_web/elements/00group-input/index.ts
Normal file
19
ts_web/elements/00group-input/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Input Components
|
||||
export * from './dees-input-base/index.js';
|
||||
export * from './dees-input-checkbox/index.js';
|
||||
export * from './dees-input-datepicker/index.js';
|
||||
export * from './dees-input-dropdown/index.js';
|
||||
export * from './dees-input-fileupload/index.js';
|
||||
export * from './dees-input-iban/index.js';
|
||||
export * from './dees-input-list/index.js';
|
||||
export * from './dees-input-multitoggle/index.js';
|
||||
export * from './dees-input-phone/index.js';
|
||||
export * from './dees-input-quantityselector/index.js';
|
||||
export * from './dees-input-radiogroup/index.js';
|
||||
export * from './dees-input-richtext/index.js';
|
||||
export * from './dees-input-searchselect/index.js';
|
||||
export * from './dees-input-tags/index.js';
|
||||
export * from './dees-input-text/index.js';
|
||||
export * from './dees-input-typelist/index.js';
|
||||
export * from './dees-input-wysiwyg.js';
|
||||
export * from './profilepicture/dees-input-profilepicture.js';
|
||||
@@ -0,0 +1,208 @@
|
||||
import { html, css } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
import '../../dees-panel/dees-panel.js';
|
||||
import './dees-input-profilepicture.js';
|
||||
import type { DeesInputProfilePicture } from './dees-input-profilepicture.js';
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
dees-panel {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-row {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 105, 242, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
margin-top: 16px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Basic demo with round profile picture
|
||||
const roundProfile = elementArg.querySelector('dees-input-profilepicture[shape="round"]');
|
||||
|
||||
if (roundProfile) {
|
||||
roundProfile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
console.log('Round profile picture changed:', target.value?.substring(0, 50) + '...');
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Profile Picture Input'} .subtitle=${'Basic usage with round and square shapes'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Profile Picture (Round)"
|
||||
description="Click to upload or drag & drop an image"
|
||||
shape="round"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Profile Picture (Square)"
|
||||
description="Supports JPEG, PNG, and WebP formats"
|
||||
shape="square"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Different sizes demo
|
||||
const profiles = elementArg.querySelectorAll('dees-input-profilepicture');
|
||||
profiles.forEach((profile) => {
|
||||
profile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
console.log(`Profile (size ${target.size}) changed`);
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<dees-panel .title=${'Size Variations'} .subtitle=${'Profile pictures in different sizes'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Small (80px)"
|
||||
shape="round"
|
||||
size="80"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Medium (120px)"
|
||||
shape="round"
|
||||
size="120"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Large (160px)"
|
||||
shape="round"
|
||||
size="160"
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Pre-filled profile with placeholder
|
||||
const sampleImageUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iZ3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjNjY3ZWVhIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0b3AtY29sb3I9IiM3NjRiYTIiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0idXJsKCNncmFkaWVudCkiIC8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI4MCIgZmlsbD0id2hpdGUiPkpEPC90ZXh0Pgo8L3N2Zz4=';
|
||||
|
||||
const prefilledProfile = elementArg.querySelector('#prefilled-profile') as DeesInputProfilePicture;
|
||||
if (prefilledProfile) {
|
||||
prefilledProfile.value = sampleImageUrl;
|
||||
|
||||
prefilledProfile.addEventListener('change', (event: CustomEvent) => {
|
||||
const target = event.target as DeesInputProfilePicture;
|
||||
const output = elementArg.querySelector('#prefilled-output');
|
||||
if (output) {
|
||||
output.textContent = target.value ?
|
||||
`Image data: ${target.value.substring(0, 80)}...` :
|
||||
'No image selected';
|
||||
}
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Pre-filled and Value Binding'} .subtitle=${'Profile picture with initial value and change tracking'}>
|
||||
<dees-input-profilepicture
|
||||
id="prefilled-profile"
|
||||
label="Edit Existing Profile"
|
||||
description="Click the edit button to change or delete to remove"
|
||||
shape="round"
|
||||
size="150"
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<div id="prefilled-output" class="demo-output">
|
||||
Image data will appear here when changed
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
// Disabled state demo
|
||||
const disabledProfile = elementArg.querySelector('#disabled-profile') as DeesInputProfilePicture;
|
||||
if (disabledProfile) {
|
||||
disabledProfile.value = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2NjY2NjYyIgLz4KICA8dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjYwIiBmaWxsPSJ3aGl0ZSI+TkE8L3RleHQ+Cjwvc3ZnPg==';
|
||||
}
|
||||
}}>
|
||||
<dees-panel .title=${'Form States'} .subtitle=${'Different states and configurations'}>
|
||||
<div class="demo-row">
|
||||
<dees-input-profilepicture
|
||||
label="Required Field"
|
||||
description="This field is required"
|
||||
shape="round"
|
||||
.required=${true}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
id="disabled-profile"
|
||||
label="Disabled State"
|
||||
description="Cannot be edited"
|
||||
shape="square"
|
||||
.disabled=${true}
|
||||
></dees-input-profilepicture>
|
||||
|
||||
<dees-input-profilepicture
|
||||
label="Upload Only"
|
||||
description="Delete not allowed"
|
||||
shape="round"
|
||||
.allowDelete=${false}
|
||||
></dees-input-profilepicture>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
|
||||
<dees-demowrapper>
|
||||
<dees-panel .title=${'Features'} .subtitle=${'Complete feature set of the profile picture input'}>
|
||||
<ul class="feature-list">
|
||||
<li><strong>Image Upload:</strong> Click to upload or drag & drop images</li>
|
||||
<li><strong>Image Cropping:</strong> Interactive crop tool with resize handles</li>
|
||||
<li><strong>Shape Support:</strong> Round or square profile pictures</li>
|
||||
<li><strong>Size Customization:</strong> Adjustable dimensions</li>
|
||||
<li><strong>Preview & Edit:</strong> Hover overlay with edit and delete options</li>
|
||||
<li><strong>File Validation:</strong> Format and size restrictions</li>
|
||||
<li><strong>Responsive Design:</strong> Works on desktop and mobile devices</li>
|
||||
<li><strong>Form Integration:</strong> Standard form value binding and validation</li>
|
||||
<li><strong>Accessibility:</strong> Keyboard navigation and screen reader support</li>
|
||||
<li><strong>Z-Index Management:</strong> Proper modal stacking with registry</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<strong>Supported Formats:</strong> JPEG, PNG, WebP<br>
|
||||
<strong>Max File Size:</strong> 5MB (configurable)<br>
|
||||
<strong>Output Format:</strong> Base64 encoded JPEG
|
||||
</div>
|
||||
</dees-panel>
|
||||
</dees-demowrapper>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,455 @@
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import '../../dees-label/dees-label.js';
|
||||
import { ProfilePictureModal } from './profilepicture.modal.js';
|
||||
import { demoFunc } from './dees-input-profilepicture.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-input-profilepicture': DeesInputProfilePicture;
|
||||
}
|
||||
}
|
||||
|
||||
export type ProfileShape = 'square' | 'round';
|
||||
|
||||
@customElement('dees-input-profilepicture')
|
||||
export class DeesInputProfilePicture extends DeesInputBase<DeesInputProfilePicture> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
@property({ type: String })
|
||||
accessor value: string = ''; // Base64 encoded image or URL
|
||||
|
||||
@property({ type: String })
|
||||
accessor shape: ProfileShape = 'round';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor size: number = 120;
|
||||
|
||||
@property({ type: String })
|
||||
accessor placeholder: string = '';
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor allowUpload: boolean = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor allowDelete: boolean = true;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor maxFileSize: number = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@property({ type: Array })
|
||||
accessor acceptedFormats: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
@property({ type: Number })
|
||||
accessor outputSize: number = 800; // Output resolution in pixels
|
||||
|
||||
@property({ type: Number })
|
||||
accessor outputQuality: number = 0.95; // 0-1 quality for JPEG
|
||||
|
||||
@state()
|
||||
accessor isHovered: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isDragging: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isLoading: boolean = false;
|
||||
|
||||
private modalInstance: ProfilePictureModal | null = null;
|
||||
|
||||
public static styles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-container:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.profile-picture {
|
||||
width: var(--size, 120px);
|
||||
height: var(--size, 120px);
|
||||
background: ${cssManager.bdTheme('#f5f5f5', '#18181b')};
|
||||
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-picture.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.profile-picture.square {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.profile-picture.dragging {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
box-shadow: 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(96, 165, 250, 0.15)')};
|
||||
}
|
||||
|
||||
.profile-picture:hover {
|
||||
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
||||
}
|
||||
|
||||
.profile-picture:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.profile-container:hover .overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overlay-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.95)', 'rgba(39, 39, 42, 0.95)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.overlay-button:hover {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#3f3f46')};
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.overlay-button.delete {
|
||||
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.9)', 'rgba(220, 38, 38, 0.9)')};
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.overlay-button.delete:hover {
|
||||
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.8)', 'rgba(0, 0, 0, 0.8)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.loading-overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-picture.clicking {
|
||||
animation: pulse 0.3s ease-out;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
||||
|
||||
<div
|
||||
class="profile-container"
|
||||
@click=${this.handleClick}
|
||||
@dragover=${this.handleDragOver}
|
||||
@dragleave=${this.handleDragLeave}
|
||||
@drop=${this.handleDrop}
|
||||
style="--size: ${this.size}px"
|
||||
>
|
||||
<div class="profile-picture ${this.shape} ${this.isDragging ? 'dragging' : ''} ${this.isLoading && !this.value ? 'clicking' : ''}">
|
||||
${this.value ? html`
|
||||
<img class="profile-image" src="${this.value}" alt="Profile picture" />
|
||||
` : html`
|
||||
<dees-icon class="placeholder-icon" icon="lucide:user" iconSize="${this.size * 0.5}"></dees-icon>
|
||||
`}
|
||||
|
||||
${this.isDragging ? html`
|
||||
<div class="overlay" style="opacity: 1">
|
||||
<div class="drop-zone-text">
|
||||
Drop image here
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.value && !this.disabled ? html`
|
||||
<div class="overlay">
|
||||
<div class="overlay-content">
|
||||
${this.allowUpload ? html`
|
||||
<button class="overlay-button" @click=${(e: Event) => { e.stopPropagation(); this.openModal(); }} title="Change picture">
|
||||
<dees-icon icon="lucide:pencil" iconSize="20"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
${this.allowDelete ? html`
|
||||
<button class="overlay-button delete" @click=${(e: Event) => { e.stopPropagation(); this.deletePicture(); }} title="Delete picture">
|
||||
<dees-icon icon="lucide:trash2" iconSize="20"></dees-icon>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${this.isLoading && !this.value ? html`
|
||||
<div class="loading-overlay show">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
class="hidden-input"
|
||||
accept="${this.acceptedFormats.join(',')}"
|
||||
@change=${this.handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleClick(): void {
|
||||
if (this.disabled || !this.allowUpload) return;
|
||||
|
||||
if (!this.value) {
|
||||
// If no image, open file picker
|
||||
this.isLoading = true;
|
||||
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
||||
|
||||
// Set up a focus handler to detect when the dialog is closed without selection
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => {
|
||||
// Check if no file was selected
|
||||
if (!input.files || input.files.length === 0) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileSelect(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
// Always reset loading state when file dialog interaction completes
|
||||
this.isLoading = false;
|
||||
|
||||
if (file) {
|
||||
this.processFile(file);
|
||||
}
|
||||
|
||||
// Reset input to allow selecting the same file again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
private handleDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.disabled && this.allowUpload) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDragLeave(): void {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
private handleDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.isDragging = false;
|
||||
|
||||
if (this.disabled || !this.allowUpload) return;
|
||||
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file) {
|
||||
this.processFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async processFile(file: File): Promise<void> {
|
||||
// Validate file type
|
||||
if (!this.acceptedFormats.includes(file.type)) {
|
||||
console.error('Invalid file type:', file.type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > this.maxFileSize) {
|
||||
console.error('File too large:', file.size);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read file as base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const base64 = e.target?.result as string;
|
||||
|
||||
// Open modal for cropping
|
||||
await this.openModal(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
private async openModal(initialImage?: string): Promise<void> {
|
||||
const imageToEdit = initialImage || this.value;
|
||||
|
||||
if (!imageToEdit) {
|
||||
// If no image provided, open file picker
|
||||
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
||||
input.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and show modal
|
||||
this.modalInstance = new ProfilePictureModal();
|
||||
this.modalInstance.shape = this.shape;
|
||||
this.modalInstance.initialImage = imageToEdit;
|
||||
this.modalInstance.outputSize = this.outputSize;
|
||||
this.modalInstance.outputQuality = this.outputQuality;
|
||||
|
||||
this.modalInstance.addEventListener('save', (event: CustomEvent) => {
|
||||
this.value = event.detail.croppedImage;
|
||||
this.changeSubject.next(this);
|
||||
});
|
||||
|
||||
document.body.appendChild(this.modalInstance);
|
||||
}
|
||||
|
||||
private deletePicture(): void {
|
||||
this.value = '';
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
public setValue(value: string): void {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
3
ts_web/elements/00group-input/profilepicture/index.ts
Normal file
3
ts_web/elements/00group-input/profilepicture/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './dees-input-profilepicture.js';
|
||||
export * from './profilepicture.modal.js';
|
||||
export * from './profilepicture.cropper.js';
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { ProfileShape } from './dees-input-profilepicture.js';
|
||||
|
||||
export interface CropperOptions {
|
||||
container: HTMLElement;
|
||||
image: string;
|
||||
shape: ProfileShape;
|
||||
aspectRatio: number;
|
||||
minSize?: number;
|
||||
outputSize?: number;
|
||||
outputQuality?: number;
|
||||
}
|
||||
|
||||
export class ImageCropper {
|
||||
private options: CropperOptions;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private img: HTMLImageElement;
|
||||
private overlayCanvas: HTMLCanvasElement;
|
||||
private overlayCtx: CanvasRenderingContext2D;
|
||||
|
||||
// Crop area properties
|
||||
private cropX: number = 0;
|
||||
private cropY: number = 0;
|
||||
private cropSize: number = 200;
|
||||
private minCropSize: number = 50;
|
||||
|
||||
// Interaction state
|
||||
private isDragging: boolean = false;
|
||||
private isResizing: boolean = false;
|
||||
private dragStartX: number = 0;
|
||||
private dragStartY: number = 0;
|
||||
private resizeHandle: string = '';
|
||||
|
||||
// Image properties
|
||||
private imageScale: number = 1;
|
||||
private imageOffsetX: number = 0;
|
||||
private imageOffsetY: number = 0;
|
||||
|
||||
constructor(options: CropperOptions) {
|
||||
this.options = {
|
||||
minSize: 50,
|
||||
outputSize: 800, // Higher default resolution
|
||||
outputQuality: 0.95, // Higher quality
|
||||
...options
|
||||
};
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d')!;
|
||||
|
||||
this.overlayCanvas = document.createElement('canvas');
|
||||
this.overlayCtx = this.overlayCanvas.getContext('2d')!;
|
||||
|
||||
this.img = new Image();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Load image
|
||||
await this.loadImage();
|
||||
|
||||
// Setup canvases
|
||||
this.setupCanvases();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Initial render
|
||||
this.render();
|
||||
}
|
||||
|
||||
private async loadImage(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.img.onload = () => resolve();
|
||||
this.img.onerror = reject;
|
||||
this.img.src = this.options.image;
|
||||
});
|
||||
}
|
||||
|
||||
private setupCanvases(): void {
|
||||
const container = this.options.container;
|
||||
const containerSize = Math.min(container.clientWidth, container.clientHeight);
|
||||
|
||||
// Set canvas sizes
|
||||
this.canvas.width = containerSize;
|
||||
this.canvas.height = containerSize;
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.style.position = 'absolute';
|
||||
this.canvas.style.top = '0';
|
||||
this.canvas.style.left = '0';
|
||||
|
||||
this.overlayCanvas.width = containerSize;
|
||||
this.overlayCanvas.height = containerSize;
|
||||
this.overlayCanvas.style.width = '100%';
|
||||
this.overlayCanvas.style.height = '100%';
|
||||
this.overlayCanvas.style.position = 'absolute';
|
||||
this.overlayCanvas.style.top = '0';
|
||||
this.overlayCanvas.style.left = '0';
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
|
||||
container.appendChild(this.canvas);
|
||||
container.appendChild(this.overlayCanvas);
|
||||
|
||||
// Calculate image scale to fit within container (not fill)
|
||||
const scale = Math.min(
|
||||
containerSize / this.img.width,
|
||||
containerSize / this.img.height
|
||||
);
|
||||
|
||||
this.imageScale = scale;
|
||||
this.imageOffsetX = (containerSize - this.img.width * scale) / 2;
|
||||
this.imageOffsetY = (containerSize - this.img.height * scale) / 2;
|
||||
|
||||
// Initialize crop area
|
||||
// Make the crop area fit within the actual image bounds
|
||||
const scaledImageWidth = this.img.width * scale;
|
||||
const scaledImageHeight = this.img.height * scale;
|
||||
const maxCropSize = Math.min(scaledImageWidth, scaledImageHeight, containerSize * 0.8);
|
||||
|
||||
this.cropSize = maxCropSize * 0.8; // Start at 80% of max possible size
|
||||
this.cropX = (containerSize - this.cropSize) / 2;
|
||||
this.cropY = (containerSize - this.cropSize) / 2;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.overlayCanvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.overlayCanvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.overlayCanvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
|
||||
|
||||
// Touch events
|
||||
this.overlayCanvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
|
||||
this.overlayCanvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||
}
|
||||
|
||||
private handleMouseDown(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
|
||||
if (handle) {
|
||||
this.isResizing = true;
|
||||
this.resizeHandle = handle;
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.isDragging = true;
|
||||
}
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
}
|
||||
|
||||
private handleMouseMove(e: MouseEvent): void {
|
||||
const rect = this.overlayCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.overlayCanvas.width / rect.width);
|
||||
const y = (e.clientY - rect.top) * (this.overlayCanvas.height / rect.height);
|
||||
|
||||
// Update cursor
|
||||
const handle = this.getResizeHandle(x, y);
|
||||
if (handle) {
|
||||
this.overlayCanvas.style.cursor = this.getResizeCursor(handle);
|
||||
} else if (this.isInsideCropArea(x, y)) {
|
||||
this.overlayCanvas.style.cursor = 'move';
|
||||
} else {
|
||||
this.overlayCanvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
// Handle dragging
|
||||
if (this.isDragging) {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
// Constrain crop area to image bounds
|
||||
const minX = this.imageOffsetX;
|
||||
const maxX = this.imageOffsetX + this.img.width * this.imageScale - this.cropSize;
|
||||
const minY = this.imageOffsetY;
|
||||
const maxY = this.imageOffsetY + this.img.height * this.imageScale - this.cropSize;
|
||||
|
||||
this.cropX = Math.max(minX, Math.min(maxX, this.cropX + dx));
|
||||
this.cropY = Math.max(minY, Math.min(maxY, this.cropY + dy));
|
||||
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
|
||||
// Handle resizing
|
||||
if (this.isResizing) {
|
||||
this.handleResize(x, y);
|
||||
this.dragStartX = x;
|
||||
this.dragStartY = y;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.resizeHandle = '';
|
||||
}
|
||||
|
||||
private handleTouchStart(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseDown(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchMove(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const mouseEvent = new MouseEvent('mousemove', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
});
|
||||
this.handleMouseMove(mouseEvent);
|
||||
}
|
||||
|
||||
private handleTouchEnd(e: TouchEvent): void {
|
||||
e.preventDefault();
|
||||
this.handleMouseUp();
|
||||
}
|
||||
|
||||
private getResizeHandle(x: number, y: number): string {
|
||||
const handleSize = 20;
|
||||
const handles = {
|
||||
'nw': { x: this.cropX, y: this.cropY },
|
||||
'ne': { x: this.cropX + this.cropSize, y: this.cropY },
|
||||
'sw': { x: this.cropX, y: this.cropY + this.cropSize },
|
||||
'se': { x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
};
|
||||
|
||||
for (const [key, pos] of Object.entries(handles)) {
|
||||
if (Math.abs(x - pos.x) < handleSize && Math.abs(y - pos.y) < handleSize) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private getResizeCursor(handle: string): string {
|
||||
const cursors: Record<string, string> = {
|
||||
'nw': 'nw-resize',
|
||||
'ne': 'ne-resize',
|
||||
'sw': 'sw-resize',
|
||||
'se': 'se-resize'
|
||||
};
|
||||
return cursors[handle] || 'default';
|
||||
}
|
||||
|
||||
private isInsideCropArea(x: number, y: number): boolean {
|
||||
return x >= this.cropX && x <= this.cropX + this.cropSize &&
|
||||
y >= this.cropY && y <= this.cropY + this.cropSize;
|
||||
}
|
||||
|
||||
private handleResize(x: number, y: number): void {
|
||||
const dx = x - this.dragStartX;
|
||||
const dy = y - this.dragStartY;
|
||||
|
||||
// Get image bounds
|
||||
const imgLeft = this.imageOffsetX;
|
||||
const imgTop = this.imageOffsetY;
|
||||
const imgRight = this.imageOffsetX + this.img.width * this.imageScale;
|
||||
const imgBottom = this.imageOffsetY + this.img.height * this.imageScale;
|
||||
|
||||
switch (this.resizeHandle) {
|
||||
case 'se':
|
||||
this.cropSize = Math.max(this.minCropSize, Math.min(
|
||||
this.cropSize + Math.max(dx, dy),
|
||||
Math.min(
|
||||
imgRight - this.cropX,
|
||||
imgBottom - this.cropY
|
||||
)
|
||||
));
|
||||
break;
|
||||
case 'nw':
|
||||
const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy));
|
||||
const sizeDiff = this.cropSize - newSize;
|
||||
const newX = this.cropX + sizeDiff;
|
||||
const newY = this.cropY + sizeDiff;
|
||||
if (newX >= imgLeft && newY >= imgTop) {
|
||||
this.cropX = newX;
|
||||
this.cropY = newY;
|
||||
this.cropSize = newSize;
|
||||
}
|
||||
break;
|
||||
case 'ne':
|
||||
const neSizeDx = Math.max(dx, -dy);
|
||||
const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx);
|
||||
const neSizeDiff = neNewSize - this.cropSize;
|
||||
const neNewY = this.cropY - neSizeDiff;
|
||||
if (neNewY >= imgTop && this.cropX + neNewSize <= imgRight) {
|
||||
this.cropY = neNewY;
|
||||
this.cropSize = neNewSize;
|
||||
}
|
||||
break;
|
||||
case 'sw':
|
||||
const swSizeDx = Math.max(-dx, dy);
|
||||
const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx);
|
||||
const swSizeDiff = swNewSize - this.cropSize;
|
||||
const swNewX = this.cropX - swSizeDiff;
|
||||
if (swNewX >= imgLeft && this.cropY + swNewSize <= imgBottom) {
|
||||
this.cropX = swNewX;
|
||||
this.cropSize = swNewSize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
// Clear canvases
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
|
||||
// Fill background
|
||||
this.ctx.fillStyle = '#000000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw image
|
||||
this.ctx.drawImage(
|
||||
this.img,
|
||||
this.imageOffsetX,
|
||||
this.imageOffsetY,
|
||||
this.img.width * this.imageScale,
|
||||
this.img.height * this.imageScale
|
||||
);
|
||||
|
||||
// Draw overlay only over the image area
|
||||
this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
this.overlayCtx.fillRect(
|
||||
this.imageOffsetX,
|
||||
this.imageOffsetY,
|
||||
this.img.width * this.imageScale,
|
||||
this.img.height * this.imageScale
|
||||
);
|
||||
|
||||
// Clear crop area
|
||||
this.overlayCtx.save();
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.clip();
|
||||
} else {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.rect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
this.overlayCtx.clip();
|
||||
}
|
||||
|
||||
this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height);
|
||||
this.overlayCtx.restore();
|
||||
|
||||
// Draw crop border
|
||||
this.overlayCtx.strokeStyle = 'white';
|
||||
this.overlayCtx.lineWidth = 2;
|
||||
|
||||
if (this.options.shape === 'round') {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(
|
||||
this.cropX + this.cropSize / 2,
|
||||
this.cropY + this.cropSize / 2,
|
||||
this.cropSize / 2,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
this.overlayCtx.stroke();
|
||||
} else {
|
||||
this.overlayCtx.strokeRect(this.cropX, this.cropY, this.cropSize, this.cropSize);
|
||||
}
|
||||
|
||||
// Draw resize handles
|
||||
this.drawResizeHandles();
|
||||
}
|
||||
|
||||
private drawResizeHandles(): void {
|
||||
const handleSize = 8;
|
||||
const handles = [
|
||||
{ x: this.cropX, y: this.cropY },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY },
|
||||
{ x: this.cropX, y: this.cropY + this.cropSize },
|
||||
{ x: this.cropX + this.cropSize, y: this.cropY + this.cropSize }
|
||||
];
|
||||
|
||||
this.overlayCtx.fillStyle = 'white';
|
||||
|
||||
handles.forEach(handle => {
|
||||
this.overlayCtx.beginPath();
|
||||
this.overlayCtx.arc(handle.x, handle.y, handleSize, 0, Math.PI * 2);
|
||||
this.overlayCtx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
async getCroppedImage(): Promise<string> {
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
const cropCtx = cropCanvas.getContext('2d')!;
|
||||
|
||||
// Calculate the actual crop size in original image pixels
|
||||
const scale = 1 / this.imageScale;
|
||||
const originalCropSize = this.cropSize * scale;
|
||||
|
||||
// Use requested output size, but warn if upscaling
|
||||
const outputSize = this.options.outputSize!;
|
||||
|
||||
if (outputSize > originalCropSize) {
|
||||
console.info(`Profile picture: Upscaling from ${Math.round(originalCropSize)}px to ${outputSize}px`);
|
||||
}
|
||||
|
||||
cropCanvas.width = outputSize;
|
||||
cropCanvas.height = outputSize;
|
||||
|
||||
// Calculate source coordinates
|
||||
const sx = (this.cropX - this.imageOffsetX) * scale;
|
||||
const sy = (this.cropY - this.imageOffsetY) * scale;
|
||||
const sSize = this.cropSize * scale;
|
||||
|
||||
// Apply shape mask if round
|
||||
if (this.options.shape === 'round') {
|
||||
cropCtx.beginPath();
|
||||
cropCtx.arc(outputSize / 2, outputSize / 2, outputSize / 2, 0, Math.PI * 2);
|
||||
cropCtx.clip();
|
||||
}
|
||||
|
||||
// Enable image smoothing for quality
|
||||
cropCtx.imageSmoothingEnabled = true;
|
||||
cropCtx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Draw cropped image
|
||||
cropCtx.drawImage(
|
||||
this.img,
|
||||
sx, sy, sSize, sSize,
|
||||
0, 0, outputSize, outputSize
|
||||
);
|
||||
|
||||
// Detect format from original image
|
||||
const isPng = this.options.image.includes('image/png');
|
||||
const format = isPng ? 'image/png' : 'image/jpeg';
|
||||
|
||||
return cropCanvas.toDataURL(format, this.options.outputQuality);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.canvas.remove();
|
||||
this.overlayCanvas.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as colors from '../../00colors.js';
|
||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||
import { zIndexRegistry } from '../../00zindex.js';
|
||||
import '../../dees-icon/dees-icon.js';
|
||||
import '../../00group-button/dees-button/dees-button.js';
|
||||
import '../../dees-windowlayer/dees-windowlayer.js';
|
||||
import { DeesWindowLayer } from '../../dees-windowlayer/dees-windowlayer.js';
|
||||
import { ImageCropper } from './profilepicture.cropper.js';
|
||||
import type { ProfileShape } from './dees-input-profilepicture.js';
|
||||
|
||||
@customElement('dees-profilepicture-modal')
|
||||
export class ProfilePictureModal extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor initialImage: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor shape: ProfileShape = 'round';
|
||||
|
||||
@property({ type: Number })
|
||||
accessor outputSize: number = 800;
|
||||
|
||||
@property({ type: Number })
|
||||
accessor outputQuality: number = 0.95;
|
||||
|
||||
@state()
|
||||
accessor currentStep: 'crop' | 'preview' = 'crop';
|
||||
|
||||
@state()
|
||||
accessor croppedImage: string = '';
|
||||
|
||||
@state()
|
||||
accessor isProcessing: boolean = false;
|
||||
|
||||
private cropper: ImageCropper | null = null;
|
||||
private windowLayer: any;
|
||||
private zIndex: number = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
font-family: ${cssGeistFontFamily};
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-index);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
opacity: 0;
|
||||
animation: modalShow 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes modalShow {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')};
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#000000', '#000000')};
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'inset 0 2px 4px rgba(0, 0, 0, 0.06)',
|
||||
'inset 0 2px 4px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border: 4px solid ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)'
|
||||
)};
|
||||
}
|
||||
|
||||
.preview-image.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.preview-image.square {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
background: ${cssManager.bdTheme('#10b981', '#10b981')};
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
animation: successPulse 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes successPulse {
|
||||
0% { transform: scale(0.9); opacity: 0; }
|
||||
50% { transform: scale(1.02); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-container {
|
||||
width: calc(100vw - 32px);
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Create window layer first (it will get its own z-index)
|
||||
this.windowLayer = await DeesWindowLayer.createAndShow({
|
||||
blur: true,
|
||||
});
|
||||
this.windowLayer.addEventListener('click', () => this.close());
|
||||
|
||||
// Now get z-index for modal (will be above window layer)
|
||||
this.zIndex = zIndexRegistry.getNextZIndex();
|
||||
this.style.setProperty('--z-index', this.zIndex.toString());
|
||||
|
||||
// Register with z-index registry
|
||||
zIndexRegistry.register(this, this.zIndex);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Cleanup
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
if (this.windowLayer) {
|
||||
await this.windowLayer.destroy();
|
||||
}
|
||||
|
||||
// Unregister from z-index registry
|
||||
zIndexRegistry.unregister(this);
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`
|
||||
<div class="modal-container" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
${this.currentStep === 'crop' ? 'Adjust Image' : 'Success'}
|
||||
</h3>
|
||||
<button class="close-button" @click=${this.close} title="Close">
|
||||
<dees-icon icon="lucide:x" iconSize="16"></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
${this.currentStep === 'crop' ? html`
|
||||
<div class="instructions">
|
||||
Position and resize the square to select your profile area
|
||||
</div>
|
||||
<div class="cropper-container" id="cropperContainer"></div>
|
||||
` : html`
|
||||
<div class="preview-container">
|
||||
${this.isProcessing ? html`
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="instructions">Saving...</div>
|
||||
` : html`
|
||||
<img
|
||||
class="preview-image ${this.shape}"
|
||||
src="${this.croppedImage}"
|
||||
alt="Cropped preview"
|
||||
/>
|
||||
<div class="success-message">
|
||||
<dees-icon icon="lucide:check" iconSize="16"></dees-icon>
|
||||
<span>Looking good!</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
${this.currentStep === 'crop' ? html`
|
||||
<dees-button type="destructive" size="sm" @click=${this.close}>
|
||||
Cancel
|
||||
</dees-button>
|
||||
<dees-button type="default" size="sm" @click=${this.handleCrop}>
|
||||
Save
|
||||
</dees-button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
if (this.currentStep === 'crop') {
|
||||
await this.initializeCropper();
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeCropper(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
|
||||
const container = this.shadowRoot!.getElementById('cropperContainer');
|
||||
if (!container) return;
|
||||
|
||||
this.cropper = new ImageCropper({
|
||||
container,
|
||||
image: this.initialImage,
|
||||
shape: this.shape,
|
||||
aspectRatio: 1,
|
||||
outputSize: this.outputSize,
|
||||
outputQuality: this.outputQuality,
|
||||
});
|
||||
|
||||
await this.cropper.initialize();
|
||||
}
|
||||
|
||||
private async handleCrop(): Promise<void> {
|
||||
if (!this.cropper) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
this.currentStep = 'preview';
|
||||
await this.updateComplete;
|
||||
|
||||
// Get cropped image
|
||||
const croppedData = await this.cropper.getCroppedImage();
|
||||
this.croppedImage = croppedData;
|
||||
|
||||
// Simulate processing time for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
this.isProcessing = false;
|
||||
|
||||
// Emit save event
|
||||
this.dispatchEvent(new CustomEvent('save', {
|
||||
detail: { croppedImage: this.croppedImage },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
|
||||
// Auto close after showing success
|
||||
setTimeout(() => {
|
||||
this.close();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error cropping image:', error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user