refactor: improve typing

This commit is contained in:
Johannes Millan 2025-09-14 21:38:07 +02:00
parent a00bb80ff3
commit f649734de4
9 changed files with 162 additions and 91 deletions

View file

@ -38,7 +38,7 @@ import {
*/
export class PluginAPI implements PluginAPIInterface {
readonly Hooks = PluginHooks;
private _hookHandlers = new Map<string, Map<Hooks, Array<PluginHookHandler<any>>>>();
private _hookHandlers = new Map<string, Map<Hooks, Array<PluginHookHandler<Hooks>>>>();
private _headerButtons: Array<PluginHeaderBtnCfg> = [];
private _menuEntries: Array<PluginMenuEntryCfg> = [];
private _shortcuts: Array<PluginShortcutCfg> = [];
@ -89,11 +89,11 @@ export class PluginAPI implements PluginAPIInterface {
pluginHooks.set(hook, []);
}
pluginHooks.get(hook)!.push(fn as PluginHookHandler<any>);
pluginHooks.get(hook)!.push(fn as PluginHookHandler<Hooks>);
PluginLog.log(`Plugin ${this._pluginId} registered hook: ${hook}`);
// Register hook with bridge
this._pluginBridge.registerHook(this._pluginId, hook, fn as PluginHookHandler<any>);
this._pluginBridge.registerHook(this._pluginId, hook, fn as PluginHookHandler<Hooks>);
}
registerHeaderButton(headerBtnCfg: PluginHeaderBtnCfg): void {

View file

@ -1,6 +1,7 @@
import { ActionReducerMap } from '@ngrx/store';
import { RootState } from './root-state';
export const reducers: ActionReducerMap<any> = {
export const reducers: Partial<ActionReducerMap<RootState>> = {
// test: (state, action) => {
// Log.log(state, action);
// return state;

View file

@ -51,11 +51,21 @@ const properties = [
const isBrowser = typeof window !== 'undefined';
const isFirefox = isBrowser && window['mozInnerScreenX'] != null;
interface CaretCoordinatesOptions {
debug?: boolean;
}
interface CaretCoordinates {
top: number;
left: number;
height: number;
}
export const getCaretCoordinates = (
element: any,
element: HTMLTextAreaElement | HTMLInputElement,
position: number,
options: any,
): any => {
options?: CaretCoordinatesOptions,
): CaretCoordinates => {
if (!isBrowser) {
throw new Error(
'textarea-caret-position#getCaretCoordinates should only be called in a browser',
@ -76,7 +86,7 @@ export const getCaretCoordinates = (
const style = div.style;
const computed = window.getComputedStyle
? window.getComputedStyle(element)
: element.currentStyle; // currentStyle for IE < 9
: (element as any).currentStyle; // currentStyle for IE < 9
const isInput = element.nodeName === 'INPUT';
// Default textarea styles

View file

@ -1,16 +1,20 @@
// configuration structure, backwards compatible with earlier versions
export interface MentionConfig extends Mentions {
export interface MentionItem {
[key: string]: unknown;
}
export interface MentionConfig<T = MentionItem> extends Mentions<T> {
// nested config
mentions?: Mentions[];
mentions?: Mentions<T>[];
// option to disable encapsulated styles so global styles can be used instead
disableStyle?: boolean;
}
export interface Mentions {
export interface Mentions<T = MentionItem> {
// an array of strings or objects to suggest
items?: any[];
items?: T[] | string[];
// the character that will trigger the menu behavior
triggerChar?: string;
@ -38,8 +42,8 @@ export interface Mentions {
returnTrigger?: boolean;
// optional function to format the selected item before inserting the text
mentionSelect?: (item: any, triggerChar?: string) => string;
mentionSelect?: (item: T | string, triggerChar?: string) => string;
// optional function to customize the search implementation
mentionFilter?: (searchString: string, items?: any) => any[];
mentionFilter?: (searchString: string, items?: T[] | string[]) => T[] | string[];
}

View file

@ -13,6 +13,7 @@ import { CommonModule } from '@angular/common';
import { isInputOrTextAreaElement, getContentEditableCaretCoords } from './mention-utils';
import { getCaretCoordinates } from './caret-coords';
import { MentionItem } from './mention-config';
import { Log } from '../../core/log';
/**
@ -61,12 +62,12 @@ import { Log } from '../../core/log';
})
export class MentionListComponent implements AfterContentChecked {
@Input() labelKey: string = 'label';
@Input() itemTemplate?: TemplateRef<any>;
@Input() itemTemplate?: TemplateRef<{ $implicit: MentionItem; index: number }>;
@Output() itemClick = new EventEmitter();
@ViewChild('list', { static: true }) list!: ElementRef;
@ViewChild('defaultItemTemplate', { static: true })
defaultItemTemplate!: TemplateRef<any>;
items = [];
defaultItemTemplate!: TemplateRef<{ $implicit: MentionItem; index: number }>;
items: MentionItem[] | string[] = [];
activeIndex: number = 0;
hidden: boolean = false;
dropUp: boolean = false;
@ -91,7 +92,7 @@ export class MentionListComponent implements AfterContentChecked {
this.coords = getCaretCoordinates(
nativeParentElement,
nativeParentElement.selectionStart || 0,
null,
undefined,
);
this.coords.top =
nativeParentElement.offsetTop + this.coords.top - nativeParentElement.scrollTop;
@ -133,7 +134,7 @@ export class MentionListComponent implements AfterContentChecked {
this.positionElement();
}
get activeItem(): any {
get activeItem(): MentionItem | string | null {
// Add bounds checking to prevent accessing undefined array elements
if (!this.items || !Array.isArray(this.items) || this.items.length === 0) {
return null;

View file

@ -1,7 +1,7 @@
// DOM element manipulation functions...
//
const setValue = (el: HTMLInputElement, value: any): void => {
const setValue = (el: HTMLInputElement, value: string): void => {
//console.log("setValue", el.nodeName, "["+value+"]");
if (isInputOrTextAreaElement(el)) {
el.value = value;

View file

@ -20,10 +20,20 @@ import {
isInputOrTextAreaElement,
} from './mention-utils';
import { MentionConfig } from './mention-config';
import { MentionConfig, MentionItem } from './mention-config';
import { MentionListComponent } from './mention-list.component';
import { Log } from '../../core/log';
// Custom types for mention events
interface CustomEvent extends Event {
wasClick?: boolean;
}
interface CustomKeyboardEvent extends KeyboardEvent {
inputEvent?: boolean;
wasClick?: boolean;
}
const KEY_BACKSPACE = 8;
const KEY_TAB = 9;
const KEY_ENTER = 13;
@ -55,9 +65,9 @@ const KEY_BUFFERED = 229;
})
export class MentionDirective implements OnChanges {
// stores the items passed to the mentions directive and used to populate the root items in mentionConfig
private mentionItems: any[] = [];
private mentionItems: MentionItem[] | string[] = [];
@Input('mention') set mention(items: any[]) {
@Input('mention') set mention(items: MentionItem[] | string[]) {
this.mentionItems = items;
}
@ -73,13 +83,19 @@ export class MentionDirective implements OnChanges {
maxItems: -1,
allowSpace: false,
returnTrigger: false,
mentionSelect: (item: any, triggerChar?: string) => {
mentionSelect: (item: MentionItem | string, triggerChar?: string) => {
// Add defensive null/undefined checks to prevent TypeError
if (!item) {
Log.warn('MentionDirective: mentionSelect called with undefined/null item');
return this.activeConfig?.triggerChar || '';
}
// Handle string items directly
if (typeof item === 'string') {
return (this.activeConfig?.triggerChar || '') + item;
}
// Handle MentionItem objects
const labelKey = this.activeConfig?.labelKey || 'label';
const itemValue = item[labelKey];
@ -90,7 +106,7 @@ export class MentionDirective implements OnChanges {
return (this.activeConfig?.triggerChar || '') + itemValue;
},
mentionFilter: (searchString: string, items: any[]) => {
mentionFilter: (searchString: string, items?: MentionItem[] | string[]) => {
if (!items || !Array.isArray(items)) {
Log.warn('MentionDirective: mentionFilter called with invalid items array');
return [];
@ -99,34 +115,46 @@ export class MentionDirective implements OnChanges {
const searchStringLowerCase = searchString.toLowerCase();
const labelKey = this.activeConfig?.labelKey || 'label';
return items.filter((e) => {
const filteredItems = items.filter((e: MentionItem | string) => {
// Add defensive checks to prevent errors during filtering
if (!e || typeof e !== 'object') {
if (!e) {
return false;
}
const itemValue = e[labelKey];
if (
itemValue === undefined ||
itemValue === null ||
typeof itemValue !== 'string'
) {
return false;
// Handle string items directly
if (typeof e === 'string') {
return e.toLowerCase().startsWith(searchStringLowerCase);
}
return itemValue.toLowerCase().startsWith(searchStringLowerCase);
// Handle MentionItem objects
if (typeof e === 'object') {
const itemValue = e[labelKey];
if (
itemValue === undefined ||
itemValue === null ||
typeof itemValue !== 'string'
) {
return false;
}
return itemValue.toLowerCase().startsWith(searchStringLowerCase);
}
return false;
});
// Return the same type as the input array
return filteredItems as typeof items;
},
};
// template to use for rendering list items
@Input() mentionListTemplate?: TemplateRef<any>;
@Input() mentionListTemplate?: TemplateRef<{ $implicit: MentionItem; index: number }>;
// event emitted whenever the search term changes
@Output() searchTerm = new EventEmitter<string>();
// event emitted when an item is selected
@Output() itemSelected = new EventEmitter<any>();
@Output() itemSelected = new EventEmitter<MentionItem | string>();
// event emitted whenever the mention list is opened or closed
@Output() opened = new EventEmitter();
@ -137,10 +165,10 @@ export class MentionDirective implements OnChanges {
private searchString: string | null = null;
private startPos: number = -1;
private startNode: any;
private startNode: Node | null = null;
private searchList?: MentionListComponent;
private searching: boolean = false;
private iframe: any; // optional
private iframe: HTMLIFrameElement | null = null; // optional
private lastKeyCode: number = 0;
private readonly _element = inject(ElementRef);
@ -178,17 +206,19 @@ export class MentionDirective implements OnChanges {
if (items && items.length > 0) {
// convert strings to objects
if (typeof items[0] == 'string') {
items = items.map((label) => {
const object: any = {};
items = (items as string[]).map((label) => {
const object: Record<string, unknown> = {};
object[config.labelKey || 'label'] = label;
return object;
});
}
if (config.labelKey) {
// remove items without an labelKey (as it's required to filter the list)
items = items.filter((e) => e[config.labelKey!]);
items = (items as MentionItem[]).filter((e) => e[config.labelKey!]);
if (!config.disableSort) {
items.sort((a, b) => a[config.labelKey!].localeCompare(b[config.labelKey!]));
(items as MentionItem[]).sort((a, b) =>
String(a[config.labelKey!]).localeCompare(String(b[config.labelKey!])),
);
}
}
}
@ -208,7 +238,7 @@ export class MentionDirective implements OnChanges {
this.iframe = iframe;
}
stopEvent(event: any): void {
stopEvent(event: CustomEvent): void {
//if (event instanceof KeyboardEvent) { // does not work for iframe
if (!event.wasClick) {
event.preventDefault();
@ -217,24 +247,27 @@ export class MentionDirective implements OnChanges {
}
}
blurHandler(event: any): void {
blurHandler(event: FocusEvent): void {
this.stopEvent(event);
this.stopSearch();
}
inputHandler(
event: any,
event: InputEvent,
nativeElement: HTMLInputElement = this._element.nativeElement,
): void {
if (this.lastKeyCode === KEY_BUFFERED && event.data) {
const keyCode = event.data.charCodeAt(0);
this.keyHandler({ keyCode, inputEvent: true }, nativeElement);
this.keyHandler(
{ keyCode, inputEvent: true } as CustomKeyboardEvent,
nativeElement,
);
}
}
// @param nativeElement is the alternative text element in an iframe scenario
keyHandler(
event: any,
event: CustomKeyboardEvent,
nativeElement: HTMLInputElement = this._element.nativeElement,
): boolean | undefined {
this.lastKeyCode = event.keyCode;
@ -270,7 +303,9 @@ export class MentionDirective implements OnChanges {
const typedLen = 1 + (this.searchString ? this.searchString.length : 0); // trigger + search
pos = this.startPos + typedLen;
setCaretPosition(
isInputOrTextAreaElement(nativeElement) ? nativeElement : (this.startNode as any),
isInputOrTextAreaElement(nativeElement)
? nativeElement
: (this.startNode as HTMLInputElement),
pos,
this.iframe,
);
@ -281,9 +316,9 @@ export class MentionDirective implements OnChanges {
if (config) {
this.activeConfig = config;
this.startPos = event.inputEvent ? pos - 1 : pos;
this.startNode = (
this.iframe ? this.iframe.contentWindow.getSelection() : window.getSelection()
).anchorNode;
this.startNode =
(this.iframe ? this.iframe.contentWindow?.getSelection() : window.getSelection())
?.anchorNode || null;
this.searching = true;
this.searchString = null;
this.showSearchList(nativeElement);
@ -329,31 +364,34 @@ export class MentionDirective implements OnChanges {
}
// emit the selected list item
this.itemSelected.emit(this.searchList.activeItem);
// optional function to format the selected item before inserting the text
const text = this.activeConfig!.mentionSelect!(
this.searchList.activeItem,
this.activeConfig!.triggerChar,
);
// value is inserted without a trailing space for consistency
// between element types (div and iframe do not preserve the space)
insertValue(nativeElement, this.startPos, pos, text, this.iframe);
// fire input event so angular bindings are updated
if ('createEvent' in document) {
const evt = document.createEvent('HTMLEvents');
if (this.iframe) {
// a 'change' event is required to trigger tinymce updates
evt.initEvent('change', true, false);
} else {
evt.initEvent('input', true, false);
const activeItem = this.searchList.activeItem;
if (activeItem) {
this.itemSelected.emit(activeItem);
// optional function to format the selected item before inserting the text
const text = this.activeConfig!.mentionSelect!(
activeItem,
this.activeConfig!.triggerChar,
);
// value is inserted without a trailing space for consistency
// between element types (div and iframe do not preserve the space)
insertValue(nativeElement, this.startPos, pos, text, this.iframe);
// fire input event so angular bindings are updated
if ('createEvent' in document) {
const evt = document.createEvent('HTMLEvents');
if (this.iframe) {
// a 'change' event is required to trigger tinymce updates
evt.initEvent('change', true, false);
} else {
evt.initEvent('input', true, false);
}
// this seems backwards, but fire the event from this elements nativeElement (not the
// one provided that may be in an iframe, as it won't be propogate)
this._element.nativeElement.dispatchEvent(evt);
}
// this seems backwards, but fire the event from this elements nativeElement (not the
// one provided that may be in an iframe, as it won't be propogate)
this._element.nativeElement.dispatchEvent(evt);
this.startPos = -1;
this.stopSearch();
return false;
}
this.startPos = -1;
this.stopSearch();
return false;
} else if (event.keyCode === KEY_ESCAPE) {
this.stopEvent(event);
this.stopSearch();
@ -406,7 +444,10 @@ export class MentionDirective implements OnChanges {
'@';
const pos = getCaretPosition(nativeElement, this.iframe);
insertValue(nativeElement, pos, pos, triggerChar, this.iframe);
this.keyHandler({ key: triggerChar, inputEvent: true }, nativeElement);
this.keyHandler(
{ key: triggerChar, inputEvent: true } as CustomKeyboardEvent,
nativeElement,
);
}
stopSearch(): void {
@ -420,7 +461,7 @@ export class MentionDirective implements OnChanges {
}
updateSearchList(): void {
let matches: any[] = [];
let matches: MentionItem[] | string[] = [];
if (this.activeConfig && this.activeConfig.items) {
let objects = this.activeConfig.items;
// disabling the search relies on the async operation to do the filtering
@ -444,7 +485,7 @@ export class MentionDirective implements OnChanges {
}
// update the search list
if (this.searchList) {
this.searchList.items = matches as any;
this.searchList.items = matches;
this.searchList.hidden = matches.length == 0;
this.listShownChange.emit(matches.length > 0);
}
@ -462,7 +503,11 @@ export class MentionDirective implements OnChanges {
this.searchList.itemTemplate = this.mentionListTemplate;
componentRef.instance['itemClick'].subscribe(() => {
nativeElement.focus();
const fakeKeydown = { key: 'Enter', keyCode: KEY_ENTER, wasClick: true };
const fakeKeydown = {
key: 'Enter',
keyCode: KEY_ENTER,
wasClick: true,
} as CustomKeyboardEvent;
this.keyHandler(fakeKeydown, nativeElement);
});
}

View file

@ -36,7 +36,6 @@ import {
} from '@angular/router';
import { APP_ROUTES } from './app/app.routes';
import { StoreModule } from '@ngrx/store';
import { reducers } from './app/root-store';
import { undoTaskDeleteMetaReducer } from './app/root-store/meta/undo-task-delete.meta-reducer';
import { actionLoggerReducer } from './app/root-store/meta/action-logger.reducer';
import {
@ -52,10 +51,10 @@ import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { ReactiveFormsModule } from '@angular/forms';
import { ServiceWorkerModule } from '@angular/service-worker';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import {
TranslateHttpLoader,
TRANSLATE_HTTP_LOADER_CONFIG,
TranslateHttpLoader,
} from '@ngx-translate/http-loader';
import { CdkDropListGroup } from '@angular/cdk/drag-drop';
import { AppComponent } from './app/app.component';
@ -102,7 +101,7 @@ bootstrapApplication(AppComponent, {
// External
BrowserModule,
// NOTE: both need to be present to use forFeature stores
StoreModule.forRoot(reducers, {
StoreModule.forRoot(undefined, {
metaReducers: [
undoTaskDeleteMetaReducer,
taskSharedCrudMetaReducer,
@ -192,7 +191,7 @@ bootstrapApplication(AppComponent, {
!IS_ANDROID_WEB_VIEW
) {
Log.log('Registering Service worker');
return navigator.serviceWorker.register('ngsw-worker.js').catch((err: any) => {
return navigator.serviceWorker.register('ngsw-worker.js').catch((err: unknown) => {
Log.log('Service Worker Registration Error');
Log.err(err);
});

25
src/typings/ical.d.ts vendored
View file

@ -1,6 +1,7 @@
// Type override for ical.js to fix TypeScript v5.8+ compatibility issue
declare module 'ical.js' {
export = ICAL;
namespace ICAL {
class Time {
static now(): Time;
@ -13,18 +14,23 @@ declare module 'ical.js' {
icaltype: string;
}
class Property {
getValues(): string[];
// Add other property methods as needed
}
class Component {
constructor(jCal: any);
constructor(jCal: ICalJCal | ICalJCal[]);
getAllSubcomponents(name?: string): Component[];
getFirstSubcomponent(name: string): Component | null;
getFirstPropertyValue(name: string): any;
getAllProperties(name: string): any[];
updatePropertyWithValue(name: string, value: any): void;
getFirstPropertyValue(name: string): ICalPropertyValue;
getAllProperties(name: string): Property[];
updatePropertyWithValue(name: string, value: ICalPropertyValue): void;
removeProperty(name: string): void;
}
class Timezone {
constructor(options: any);
constructor(options: ICalTimezoneOptions);
tzid: string;
}
@ -38,7 +44,12 @@ declare module 'ical.js' {
updateTimezones(comp: Component): Component;
};
function parse(icalData: string): any;
function stringify(jCal: any): string;
function parse(icalData: string): ICalJCal[];
function stringify(jCal: ICalJCal | ICalJCal[]): string;
// Base types for ical.js
type ICalJCal = [string, Record<string, unknown>[], unknown[]];
type ICalPropertyValue = string | number | Date | boolean | unknown[] | Time;
type ICalTimezoneOptions = Record<string, unknown>;
}
}