feat(elements): add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies
This commit is contained in:
502
ts_web/elements/eco-provider-frame/eco-provider-frame.ts
Normal file
502
ts_web/elements/eco-provider-frame/eco-provider-frame.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
query,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||
import type {
|
||||
IDataProvider,
|
||||
IProviderMessage,
|
||||
IEcobridgeMessage,
|
||||
IProviderReadyPayload,
|
||||
IProviderResponsePayload,
|
||||
IProviderDataOfferPayload,
|
||||
IFeatureChangeRequest,
|
||||
TProviderFeature,
|
||||
TProviderStatus,
|
||||
ISendDataPayload,
|
||||
IRequestDataPayload,
|
||||
} from '../interfaces/dataprovider.js';
|
||||
import { demo } from './eco-provider-frame.demo.js';
|
||||
|
||||
// Ensure components are registered
|
||||
DeesIcon;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'eco-provider-frame': EcoProviderFrame;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('eco-provider-frame')
|
||||
export class EcoProviderFrame extends DeesElement {
|
||||
public static demo = demo;
|
||||
public static demoGroup = 'Elements';
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.provider-frame-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
background: ${cssManager.bdTheme('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.8)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-overlay dees-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
|
||||
z-index: 10;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-overlay .error-icon {
|
||||
color: hsl(0 72% 50%);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: hsl(0 72% 50%);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
margin-top: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: hsl(217 91% 60%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: hsl(217 91% 55%);
|
||||
}
|
||||
|
||||
.disconnected-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||
z-index: 10;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: String })
|
||||
accessor providerId = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor providerUrl = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor providerName = '';
|
||||
|
||||
@property({ type: Array })
|
||||
accessor confirmedFeatures: TProviderFeature[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor sandboxed = true;
|
||||
|
||||
@state()
|
||||
accessor status: TProviderStatus = 'loading';
|
||||
|
||||
@state()
|
||||
accessor error: string | null = null;
|
||||
|
||||
@state()
|
||||
accessor currentFeatures: TProviderFeature[] = [];
|
||||
|
||||
@query('iframe')
|
||||
accessor iframe: HTMLIFrameElement | null;
|
||||
|
||||
private messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||
private pendingRequests: Map<string, {
|
||||
resolve: (value: IProviderResponsePayload) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}> = new Map();
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.setupMessageListener();
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.cleanupMessageListener();
|
||||
this.clearPendingRequests();
|
||||
}
|
||||
|
||||
private setupMessageListener(): void {
|
||||
this.messageHandler = (event: MessageEvent) => {
|
||||
this.handleProviderMessage(event);
|
||||
};
|
||||
window.addEventListener('message', this.messageHandler);
|
||||
}
|
||||
|
||||
private cleanupMessageListener(): void {
|
||||
if (this.messageHandler) {
|
||||
window.removeEventListener('message', this.messageHandler);
|
||||
this.messageHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingRequests(): void {
|
||||
for (const [requestId, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error('Provider disconnected'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="provider-frame-container">
|
||||
${this.providerUrl ? html`
|
||||
<iframe
|
||||
src=${this.providerUrl}
|
||||
sandbox=${this.sandboxed ? 'allow-scripts allow-same-origin allow-forms' : ''}
|
||||
@load=${this.handleIframeLoad}
|
||||
@error=${this.handleIframeError}
|
||||
></iframe>
|
||||
` : ''}
|
||||
${this.renderOverlay()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOverlay(): TemplateResult | null {
|
||||
switch (this.status) {
|
||||
case 'loading':
|
||||
return html`
|
||||
<div class="loading-overlay">
|
||||
<dees-icon .icon=${'lucide:loader'} .iconSize=${32}></dees-icon>
|
||||
<span>Loading provider...</span>
|
||||
</div>
|
||||
`;
|
||||
case 'error':
|
||||
return html`
|
||||
<div class="error-overlay">
|
||||
<dees-icon class="error-icon" .icon=${'lucide:alertCircle'} .iconSize=${48}></dees-icon>
|
||||
<span class="error-title">Connection Error</span>
|
||||
<span class="error-message">${this.error || 'Failed to connect to provider'}</span>
|
||||
<button class="retry-button" @click=${this.retry}>Retry</button>
|
||||
</div>
|
||||
`;
|
||||
case 'disconnected':
|
||||
return html`
|
||||
<div class="disconnected-overlay">
|
||||
<dees-icon .icon=${'lucide:unplug'} .iconSize=${32}></dees-icon>
|
||||
<span>Provider disconnected</span>
|
||||
</div>
|
||||
`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleIframeLoad(): void {
|
||||
// Start waiting for provider-ready message
|
||||
// If not received within timeout, show error
|
||||
setTimeout(() => {
|
||||
if (this.status === 'loading') {
|
||||
this.status = 'error';
|
||||
this.error = 'Provider did not respond. Make sure it implements the ecobridge provider protocol.';
|
||||
}
|
||||
}, 10000); // 10 second timeout for provider to declare ready
|
||||
}
|
||||
|
||||
private handleIframeError(): void {
|
||||
this.status = 'error';
|
||||
this.error = 'Failed to load provider URL';
|
||||
}
|
||||
|
||||
private handleProviderMessage(event: MessageEvent): void {
|
||||
// Verify origin matches provider URL
|
||||
if (!this.providerUrl) return;
|
||||
|
||||
try {
|
||||
const providerOrigin = new URL(this.providerUrl).origin;
|
||||
if (event.origin !== providerOrigin) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data as IProviderMessage;
|
||||
if (!message || typeof message.type !== 'string') return;
|
||||
|
||||
// Only accept messages from this provider
|
||||
if (message.providerId && message.providerId !== this.providerId) return;
|
||||
|
||||
switch (message.type) {
|
||||
case 'provider-ready':
|
||||
this.handleProviderReady(message.payload as IProviderReadyPayload);
|
||||
break;
|
||||
case 'provider-features':
|
||||
this.handleFeaturesUpdate(message.payload as { features: TProviderFeature[] });
|
||||
break;
|
||||
case 'provider-response':
|
||||
this.handleProviderResponse(message.requestId!, message.payload as IProviderResponsePayload);
|
||||
break;
|
||||
case 'provider-error':
|
||||
this.handleProviderError(message.payload as { error: string });
|
||||
break;
|
||||
case 'data-offer':
|
||||
this.handleDataOffer(message.payload as IProviderDataOfferPayload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleProviderReady(payload: IProviderReadyPayload): void {
|
||||
this.currentFeatures = payload.features || [];
|
||||
|
||||
// Check for feature changes
|
||||
if (this.confirmedFeatures.length > 0) {
|
||||
const added = this.currentFeatures.filter(f => !this.confirmedFeatures.includes(f));
|
||||
const removed = this.confirmedFeatures.filter(f => !this.currentFeatures.includes(f));
|
||||
|
||||
if (added.length > 0 || removed.length > 0) {
|
||||
const request: IFeatureChangeRequest = {
|
||||
providerId: this.providerId,
|
||||
providerName: payload.name || this.providerName,
|
||||
addedFeatures: added,
|
||||
removedFeatures: removed,
|
||||
currentFeatures: this.currentFeatures,
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('provider-features-changed', {
|
||||
detail: { request },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
this.status = 'connected';
|
||||
|
||||
const provider: IDataProvider = {
|
||||
id: this.providerId,
|
||||
name: payload.name || this.providerName,
|
||||
url: this.providerUrl,
|
||||
features: this.currentFeatures,
|
||||
confirmedFeatures: this.confirmedFeatures,
|
||||
icon: payload.icon,
|
||||
lastSeen: new Date(),
|
||||
status: 'connected',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('provider-ready', {
|
||||
detail: { provider },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleFeaturesUpdate(payload: { features: TProviderFeature[] }): void {
|
||||
const previousFeatures = [...this.currentFeatures];
|
||||
this.currentFeatures = payload.features || [];
|
||||
|
||||
const added = this.currentFeatures.filter(f => !previousFeatures.includes(f));
|
||||
const removed = previousFeatures.filter(f => !this.currentFeatures.includes(f));
|
||||
|
||||
if (added.length > 0 || removed.length > 0) {
|
||||
const request: IFeatureChangeRequest = {
|
||||
providerId: this.providerId,
|
||||
providerName: this.providerName,
|
||||
addedFeatures: added,
|
||||
removedFeatures: removed,
|
||||
currentFeatures: this.currentFeatures,
|
||||
};
|
||||
|
||||
this.dispatchEvent(new CustomEvent('provider-features-changed', {
|
||||
detail: { request },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private handleProviderResponse(requestId: string, response: IProviderResponsePayload): void {
|
||||
const pending = this.pendingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(requestId);
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('provider-response', {
|
||||
detail: { requestId, response },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleProviderError(payload: { error: string }): void {
|
||||
this.dispatchEvent(new CustomEvent('provider-error', {
|
||||
detail: { error: payload.error },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleDataOffer(offer: IProviderDataOfferPayload): void {
|
||||
this.dispatchEvent(new CustomEvent('provider-data-offer', {
|
||||
detail: { offer },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Public API
|
||||
public retry(): void {
|
||||
this.status = 'loading';
|
||||
this.error = null;
|
||||
if (this.iframe) {
|
||||
this.iframe.src = this.providerUrl;
|
||||
}
|
||||
}
|
||||
|
||||
public sendMessage(message: IEcobridgeMessage): void {
|
||||
if (!this.iframe?.contentWindow) {
|
||||
console.warn('Cannot send message: iframe not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const origin = new URL(this.providerUrl).origin;
|
||||
this.iframe.contentWindow.postMessage(message, origin);
|
||||
} catch (e) {
|
||||
console.error('Failed to send message to provider:', e);
|
||||
}
|
||||
}
|
||||
|
||||
public async sendData(data: ISendDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
this.sendMessage({
|
||||
type: 'send-data',
|
||||
requestId,
|
||||
payload: data,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async requestData(request: IRequestDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(requestId);
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||
|
||||
this.sendMessage({
|
||||
type: 'request-data',
|
||||
requestId,
|
||||
payload: request,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public ping(): void {
|
||||
this.sendMessage({
|
||||
type: 'ping',
|
||||
requestId: crypto.randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
public hasFeature(feature: TProviderFeature): boolean {
|
||||
return this.confirmedFeatures.includes(feature);
|
||||
}
|
||||
|
||||
public getStatus(): TProviderStatus {
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user