fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<div
|
||||
appThemeNode="contextMenuSurface"
|
||||
class="w-80 rounded-lg border border-border bg-card p-3 shadow-xl"
|
||||
role="menu"
|
||||
aria-label="Plugin actions"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground">Plugins</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ actions().length }} available actions</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close plugin menu"
|
||||
title="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (actions().length > 0) {
|
||||
<div class="grid max-h-80 grid-cols-3 gap-2 overflow-auto pr-1">
|
||||
@for (record of actions(); track record.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-h-20 flex-col items-center justify-start gap-2 rounded-md px-2 py-2 text-center transition-colors hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||
role="menuitem"
|
||||
[attr.aria-label]="actionTitle(record)"
|
||||
[title]="actionTitle(record)"
|
||||
(click)="runAction(record)"
|
||||
>
|
||||
<span
|
||||
class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-md border border-border bg-secondary text-xs font-semibold text-foreground transition-colors group-hover:border-primary/40"
|
||||
>
|
||||
@if (isImageIcon(record)) {
|
||||
<img
|
||||
class="h-full w-full object-cover"
|
||||
[src]="iconText(record)"
|
||||
[alt]="record.contribution.label"
|
||||
/>
|
||||
} @else {
|
||||
<span class="max-w-full truncate px-1">{{ iconText(record) }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="line-clamp-2 min-h-8 text-[11px] font-medium leading-4 text-foreground">
|
||||
{{ record.contribution.label }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="rounded-md border border-dashed border-border bg-background/40 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No plugin actions available.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
import type { PluginApiActionContribution } from '../../domain/models/plugin-api.models';
|
||||
import { PluginClientApiService } from '../../application/services/plugin-client-api.service';
|
||||
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
|
||||
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
|
||||
import type { PluginUiContributionRecord } from '../../application/services/plugin-ui-registry.service';
|
||||
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plugin-action-menu',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideX })],
|
||||
templateUrl: './plugin-action-menu.component.html'
|
||||
})
|
||||
export class PluginActionMenuComponent {
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly pluginApi = inject(PluginClientApiService);
|
||||
private readonly pluginRegistry = inject(PluginRegistryService);
|
||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||
|
||||
readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
|
||||
.sort((left, right) => this.sortActionRecords(left, right)));
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
close(): void {
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
|
||||
runAction(record: PluginUiContributionRecord<PluginApiActionContribution>): void {
|
||||
this.closed.emit(undefined);
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => record.contribution.run(this.pluginApi.createActionContext('toolbarAction')))
|
||||
.catch((error: unknown) => this.logger.error(record.pluginId, 'Toolbar action failed', error));
|
||||
}
|
||||
|
||||
pluginName(pluginId: string): string {
|
||||
return this.pluginRegistry.find(pluginId)?.manifest.title ?? pluginId;
|
||||
}
|
||||
|
||||
actionTitle(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
|
||||
return `${this.pluginName(record.pluginId)}: ${record.contribution.label}`;
|
||||
}
|
||||
|
||||
iconText(record: PluginUiContributionRecord<PluginApiActionContribution>): string {
|
||||
const icon = record.contribution.icon?.trim();
|
||||
|
||||
if (icon) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
return createInitials(this.pluginName(record.pluginId), record.contribution.label);
|
||||
}
|
||||
|
||||
isImageIcon(record: PluginUiContributionRecord<PluginApiActionContribution>): boolean {
|
||||
const icon = record.contribution.icon?.trim() ?? '';
|
||||
|
||||
return icon.startsWith('http://')
|
||||
|| icon.startsWith('https://')
|
||||
|| icon.startsWith('data:image/')
|
||||
|| icon.startsWith('blob:');
|
||||
}
|
||||
|
||||
private sortActionRecords(
|
||||
left: PluginUiContributionRecord<PluginApiActionContribution>,
|
||||
right: PluginUiContributionRecord<PluginApiActionContribution>
|
||||
): number {
|
||||
const leftPlugin = this.pluginName(left.pluginId);
|
||||
const rightPlugin = this.pluginName(right.pluginId);
|
||||
const pluginCompare = leftPlugin.localeCompare(rightPlugin);
|
||||
|
||||
if (pluginCompare !== 0) {
|
||||
return pluginCompare;
|
||||
}
|
||||
|
||||
return left.contribution.label.localeCompare(right.contribution.label);
|
||||
}
|
||||
}
|
||||
|
||||
function createInitials(pluginName: string, actionLabel: string): string {
|
||||
const words = `${pluginName} ${actionLabel}`
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.filter((word) => word.length > 0);
|
||||
|
||||
if (words.length === 0) {
|
||||
return 'PL';
|
||||
}
|
||||
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ElementRef,
|
||||
Injectable,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ConnectedPosition,
|
||||
Overlay,
|
||||
OverlayRef
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import {
|
||||
Subscription,
|
||||
filter,
|
||||
fromEvent
|
||||
} from 'rxjs';
|
||||
import { PluginActionMenuComponent } from './plugin-action-menu.component';
|
||||
|
||||
const GAP = 10;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const POSITIONS: ConnectedPosition[] = [
|
||||
{ originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: GAP },
|
||||
{ originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: GAP },
|
||||
{ originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -GAP },
|
||||
{ originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: -GAP }
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginActionMenuService {
|
||||
private readonly overlay = inject(Overlay);
|
||||
private currentOrigin: HTMLElement | null = null;
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private overlaySubscriptions: Subscription | null = null;
|
||||
private scrollBlocker: (() => void) | null = null;
|
||||
|
||||
open(origin: ElementRef | HTMLElement): void {
|
||||
const rawEl = origin instanceof ElementRef ? origin.nativeElement : origin;
|
||||
|
||||
if (this.overlayRef) {
|
||||
const sameOrigin = rawEl === this.currentOrigin;
|
||||
|
||||
this.close();
|
||||
|
||||
if (sameOrigin) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
|
||||
|
||||
this.currentOrigin = rawEl;
|
||||
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.flexibleConnectedTo(elementRef)
|
||||
.withPositions(POSITIONS)
|
||||
.withViewportMargin(VIEWPORT_MARGIN)
|
||||
.withPush(true);
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop()
|
||||
});
|
||||
|
||||
this.syncThemeVars();
|
||||
|
||||
const componentRef = this.overlayRef.attach(new ComponentPortal(PluginActionMenuComponent));
|
||||
const subscriptions = new Subscription();
|
||||
|
||||
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
|
||||
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
|
||||
.pipe(
|
||||
filter((event) => {
|
||||
const target = event.target as Node;
|
||||
|
||||
if (this.overlayRef?.overlayElement.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.currentOrigin?.contains(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.subscribe(() => this.close()));
|
||||
|
||||
this.overlaySubscriptions = subscriptions;
|
||||
this.blockScroll();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.scrollBlocker?.();
|
||||
this.scrollBlocker = null;
|
||||
this.overlaySubscriptions?.unsubscribe();
|
||||
this.overlaySubscriptions = null;
|
||||
|
||||
if (this.overlayRef) {
|
||||
this.overlayRef.dispose();
|
||||
this.overlayRef = null;
|
||||
this.currentOrigin = null;
|
||||
}
|
||||
}
|
||||
|
||||
private blockScroll(): void {
|
||||
const handler = (event: Event): void => {
|
||||
if (this.overlayRef?.overlayElement.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
const opts: AddEventListenerOptions = { passive: false, capture: true };
|
||||
|
||||
document.addEventListener('wheel', handler, opts);
|
||||
document.addEventListener('touchmove', handler, opts);
|
||||
|
||||
this.scrollBlocker = () => {
|
||||
document.removeEventListener('wheel', handler, opts);
|
||||
document.removeEventListener('touchmove', handler, opts);
|
||||
};
|
||||
}
|
||||
|
||||
private syncThemeVars(): void {
|
||||
const appRoot = document.querySelector<HTMLElement>('[data-theme-key="appRoot"]');
|
||||
const container = document.querySelector<HTMLElement>('.cdk-overlay-container');
|
||||
|
||||
if (!appRoot || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const prop of Array.from(appRoot.style)) {
|
||||
if (prop.startsWith('--')) {
|
||||
container.style.setProperty(prop, appRoot.style.getPropertyValue(prop));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user