UNPKG

@handsontable/angular-wrapper

Version:

Best Data Grid for Angular with Spreadsheet Look and Feel.

1,088 lines (1,077 loc) 73.3 kB
import * as i0 from '@angular/core'; import { ViewContainerRef, ViewChild, Input, ChangeDetectionStrategy, Component, createComponent, Directive, EventEmitter, Output, HostBinding, Injectable, InjectionToken, Inject, inject, DestroyRef, ViewEncapsulation, NgModule } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import Handsontable from 'handsontable/base'; import { take, skip } from 'rxjs/operators'; import { editorFactory } from 'handsontable/editors/factory'; import { baseRenderer, rendererFactory, registerRenderer } from 'handsontable/renderers'; import { BehaviorSubject } from 'rxjs'; /** * Component representing a placeholder for a custom editor in Handsontable. * It is used only within the wrapper. */ class CustomEditorPlaceholderComponent { /** The top position of the editor. */ top; /** The left position of the editor. */ left; /** The height of the editor. */ height; /** The width of the editor. */ width; set isVisible(value) { this._isVisible = value; } placeholderCustomClass = 'handsontableInputHolder ht_clone_master'; /** The reference to the component instance of the editor. */ set componentRef(hotEditorComponentRef) { if (hotEditorComponentRef) { this.container.insert(hotEditorComponentRef.hostView); } } /** The container for the editor's input placeholder. */ container; /** The display style of the editor. */ get display() { return this._isVisible ? 'block' : 'none'; } _isVisible = false; /** * Detaches the container from the Handsontable. */ detachEditor() { this.container.detach(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: CustomEditorPlaceholderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: CustomEditorPlaceholderComponent, isStandalone: true, selector: "ng-component", inputs: { top: "top", left: "left", height: "height", width: "width", isVisible: "isVisible", placeholderCustomClass: "placeholderCustomClass", componentRef: "componentRef" }, viewQueries: [{ propertyName: "container", first: true, predicate: ["inputPlaceholder"], descendants: true, read: ViewContainerRef, static: true }], ngImport: i0, template: ` <div [class]="placeholderCustomClass" [style.display]="display" [style.width.px]="width" [style.height.px]="height" [style.maxWidth.px]="width" [style.maxHeight.px]="height" [style.top.px]="top" [style.left.px]="left" > <ng-template #inputPlaceholder></ng-template> </div>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: CustomEditorPlaceholderComponent, decorators: [{ type: Component, args: [{ template: ` <div [class]="placeholderCustomClass" [style.display]="display" [style.width.px]="width" [style.height.px]="height" [style.maxWidth.px]="width" [style.maxHeight.px]="height" [style.top.px]="top" [style.left.px]="left" > <ng-template #inputPlaceholder></ng-template> </div>`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }] }], propDecorators: { top: [{ type: Input }], left: [{ type: Input }], height: [{ type: Input }], width: [{ type: Input }], isVisible: [{ type: Input }], placeholderCustomClass: [{ type: Input }], componentRef: [{ type: Input }], container: [{ type: ViewChild, args: ['inputPlaceholder', { read: ViewContainerRef, static: true }] }] } }); /** * Adapter for BaseEditor from Handsontable. */ class BaseEditorAdapter extends Handsontable.editors.BaseEditor { /** Reference to the custom editor component. */ _componentRef; /** Reference to the editor placeholder component. */ _editorPlaceHolderRef; /** Flag indicating whether the placeholder is ready. */ _isPlaceholderReady = false; /** Subscription for the finish edit event. */ _finishEditSubscription; /** Subscription for the cancel edit event. */ _cancelEditSubscription; /** * Creates an instance of BaseEditorAdapter. * @param instance The Handsontable instance. */ constructor(instance) { super(instance); this.hot.addHook('afterRowResize', this.onAfterRowResize.bind(this)); this.hot.addHook('afterColumnResize', this.onAfterColumnResize.bind(this)); this.hot.addHook('afterDestroy', this.onAfterDestroy.bind(this)); } /** * Prepares the editor for editing. Parameters are passed from Handsontable. * @param row The row index. * @param column The column index. * @param prop The property name. * @param TD The table cell element. * @param originalValue The original value of the cell. * @param cellProperties The cell properties. */ prepare(row, column, prop, TD, originalValue, cellProperties) { if (!this.isOpened()) { super.prepare(row, column, prop, TD, originalValue, cellProperties); const columnMeta = this.hot.getColumnMeta(column); if (!this._isPlaceholderReady) { this.createEditorPlaceholder(columnMeta._environmentInjector); this._isPlaceholderReady = true; } this._componentRef = columnMeta._editorComponentReference; this.cleanupSubscriptions(); this._finishEditSubscription = this._componentRef.instance.finishEdit .pipe(take(1)) .subscribe(() => { this.finishEditing(); }); this._cancelEditSubscription = this._componentRef.instance.cancelEdit .pipe(take(1)) .subscribe(() => { this.cancelChanges(); }); } } /** * Closes the editor. This event is triggered by Handsontable. */ close() { if (this.isOpened()) { this.resetEditorState(); this._editorPlaceHolderRef?.changeDetectorRef.detectChanges(); this._editorPlaceHolderRef?.instance.detachEditor(); this._componentRef?.instance.onClose(); } } /** * Focuses the editor. This event is triggered by Handsontable. */ focus() { this._componentRef?.instance.onFocus(); } /** * Gets the value from the editor. * @returns The value from the editor. */ getValue() { return this._componentRef?.instance?.getValue(); } /** * Opens the editor. This event is triggered by Handsontable. * When opening, we set the shortcut context to 'editor'. * This allows the built-in keyboard shortcuts to operate within the editor. * @param event The event that triggered the opening of the editor. * @remarks When entering edit mode using double-click, keyboard shortcuts do not work. */ open(event) { this.hot.getShortcutManager().setActiveContextName('editor'); this.applyPropsToEditor(); this._componentRef?.instance.onOpen(event); } /** * Sets the value for the custom editor. * @param newValue The value to set. */ setValue(newValue) { this._componentRef?.instance?.setValue(newValue); this._componentRef?.changeDetectorRef.detectChanges(); } /** * Applies properties to the custom editor and editor placeholder. */ applyPropsToEditor() { if (!this._componentRef || !this._editorPlaceHolderRef) { return; } const rect = this.getEditedCellRect(); if (!this.isInFullEditMode()) { this._componentRef.instance.setValue(null); } this._componentRef.setInput('originalValue', this.originalValue); this._componentRef.setInput('row', this.row); this._componentRef.setInput('column', this.col); this._componentRef.setInput('prop', this.prop); this._componentRef.setInput('cellProperties', this.cellProperties); this._editorPlaceHolderRef.setInput('top', rect.top); this._editorPlaceHolderRef.setInput('left', rect.start); this._editorPlaceHolderRef.setInput('height', rect.height); this._editorPlaceHolderRef.setInput('width', rect.width); this._editorPlaceHolderRef.setInput('isVisible', true); this._editorPlaceHolderRef.setInput('componentRef', this._componentRef); this._editorPlaceHolderRef.changeDetectorRef.detectChanges(); } /** * Creates the editor placeholder and append it to hot rootElement. * @param injector The environment injector. */ createEditorPlaceholder(injector) { this._editorPlaceHolderRef = createComponent(CustomEditorPlaceholderComponent, { environmentInjector: injector, }); this.hot.rootElement.appendChild(this._editorPlaceHolderRef.location.nativeElement); } /** * Handles the after column resize event. * Helps adjust the editor size to the column size and update its position. */ onAfterColumnResize() { if (this.isOpened()) { this.applyPropsToEditor(); } } /** * Handles the after row resize event. * Helps adjust the editor size to the column size and update its position. */ onAfterRowResize() { if (this.isOpened()) { this.applyPropsToEditor(); } } /** * Handles the after destroy event. */ onAfterDestroy() { this.cleanupSubscriptions(); this._editorPlaceHolderRef?.destroy(); } /** * Unsubscribes the finish/cancel edit subscriptions if they are still active. * Without this, destroying the table while an editor was prepared but never * finished/cancelled leaves the `take(1)` subscriptions hanging (they never emit). */ cleanupSubscriptions() { if (this._finishEditSubscription) { this._finishEditSubscription.unsubscribe(); this._finishEditSubscription = undefined; } if (this._cancelEditSubscription) { this._cancelEditSubscription.unsubscribe(); this._cancelEditSubscription = undefined; } } /** * Resets the editor placeholder state. * We need to reset the editor placeholder state because we use it * to store multiple references to the custom editor. */ resetEditorState() { if (!this._editorPlaceHolderRef) { return; } this._editorPlaceHolderRef.setInput('top', undefined); this._editorPlaceHolderRef.setInput('left', undefined); this._editorPlaceHolderRef.setInput('height', undefined); this._editorPlaceHolderRef.setInput('width', undefined); this._editorPlaceHolderRef.setInput('isVisible', false); this._editorPlaceHolderRef.setInput('componentRef', undefined); } } /** * Shared base directive for HotCellRendererComponent and HotCellRendererAdvancedComponent. * Holds all @Input() properties and getProps() that both renderer variants share. * * @template TValue - The type of the rendered cell value. * @template TProps - The type of additional renderer properties. */ class HotCellRendererBase { value = ''; instance; td; row; col; prop; cellProperties; getProps() { return this.cellProperties?.rendererProps ?? {}; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.25", type: HotCellRendererBase, isStandalone: true, inputs: { value: "value", instance: "instance", td: "td", row: "row", col: "col", prop: "prop", cellProperties: "cellProperties" }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererBase, decorators: [{ type: Directive }], propDecorators: { value: [{ type: Input }], instance: [{ type: Input }], td: [{ type: Input }], row: [{ type: Input }], col: [{ type: Input }], prop: [{ type: Input }], cellProperties: [{ type: Input }] } }); /** * Abstract base component for creating custom cell renderer components for Handsontable. * * Extend this component and provide your own template to implement a custom renderer. * Value type is limited to primitives (`string | number | boolean`). * For object and array values use {@link HotCellRendererAdvancedComponent}. * * @template TValue - The type of the component renderer. * @template TProps - The type of additional renderer properties. */ class HotCellRendererComponent extends HotCellRendererBase { static RENDERER_MARKER = Symbol('HotCellRendererComponent'); static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: HotCellRendererComponent, isStandalone: true, selector: "hot-cell-renderer", usesInheritance: true, ngImport: i0, template: `<!-- This is an abstract component. Extend this component and provide your own template. -->`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererComponent, decorators: [{ type: Component, args: [{ selector: 'hot-cell-renderer', template: `<!-- This is an abstract component. Extend this component and provide your own template. -->`, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }] }] }); /** * Shared base directive for HotCellEditorComponent and HotCellEditorAdvancedComponent. * Holds all @Input(), @Output() and @HostBinding() declarations that both editor variants share. * * @template T - The type of the edited cell value. */ class HotCellEditorBase { heightFitParentContainer = 100; widthFitParentContainer = 100; row; column; prop; originalValue; cellProperties; finishEdit = new EventEmitter(); cancelEdit = new EventEmitter(); value; getValue() { return this.value; } setValue(value) { this.value = value; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorBase, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.25", type: HotCellEditorBase, isStandalone: true, inputs: { row: "row", column: "column", prop: "prop", originalValue: "originalValue", cellProperties: "cellProperties" }, outputs: { finishEdit: "finishEdit", cancelEdit: "cancelEdit" }, host: { properties: { "style.height.%": "this.heightFitParentContainer", "style.width.%": "this.widthFitParentContainer" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorBase, decorators: [{ type: Directive }], propDecorators: { heightFitParentContainer: [{ type: HostBinding, args: ['style.height.%'] }], widthFitParentContainer: [{ type: HostBinding, args: ['style.width.%'] }], row: [{ type: Input }], column: [{ type: Input }], prop: [{ type: Input }], originalValue: [{ type: Input }], cellProperties: [{ type: Input }], finishEdit: [{ type: Output }], cancelEdit: [{ type: Output }] } }); /** * Abstract class representing a basic Handsontable cell editor in Angular. * * Extend this class and decorate the subclass with `@Component()` to implement a custom editor. * Value type is limited to primitives (`string | number | boolean`). * For object and array values use {@link HotCellEditorAdvancedComponent}. */ class HotCellEditorComponent extends HotCellEditorBase { static EDITOR_MARKER = Symbol('HotCellEditorComponent'); /** The tabindex attribute for the editor. */ tabindex = -1; /** The data-hot-input attribute for the editor. */ dataHotInput = ''; /** The handsontableInput class for the editor. */ handsontableInputClass = true; /** Event triggered by Handsontable on closing the editor. */ onClose() { } /** Event triggered by Handsontable on opening the editor. */ onOpen(event) { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.25", type: HotCellEditorComponent, isStandalone: true, host: { properties: { "attr.tabindex": "this.tabindex", "attr.data-hot-input": "this.dataHotInput", "class.handsontableInput": "this.handsontableInputClass" } }, usesInheritance: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorComponent, decorators: [{ type: Directive }], propDecorators: { tabindex: [{ type: HostBinding, args: ['attr.tabindex'] }], dataHotInput: [{ type: HostBinding, args: ['attr.data-hot-input'] }], handsontableInputClass: [{ type: HostBinding, args: ['class.handsontableInput'] }] } }); /** * Factory function to create a custom Handsontable editor adapter for Angular components. * * This adapter integrates Angular components with Handsontable's editor system using the new * editorFactory API, allowing you to use Angular components as custom cell editors while * maintaining full Angular lifecycle management and change detection. * * @returns A custom editor class that can be used in Handsontable column settings. * */ const FactoryEditorAdapter = (componentRef) => editorFactory({ position: componentRef.instance.position, shortcuts: componentRef.instance.shortcuts, config: componentRef.instance.config, init(editor) { editor._componentRef = componentRef; editor._editorPlaceHolderRef = undefined; editor._finishEditSubscription = undefined; editor._cancelEditSubscription = undefined; createEditorPlaceholder(editor, editor.hot._angularEnvironmentInjector); editor.input = editor._editorPlaceHolderRef?.location.nativeElement ?? document.createElement('div'); editor._afterRowResizeCallback = () => { if (editor.isOpened()) { applyPropsToEditor(editor); } }; editor._afterColumnResizeCallback = () => { if (editor.isOpened()) { applyPropsToEditor(editor); } }; editor._afterDestroyCallback = () => { cleanupSubscriptions(editor); if (editor._editorPlaceHolderRef) { editor._editorPlaceHolderRef.destroy(); } }; // Hooks are automatically removed by Handsontable on table destroy editor.hot.addHook('afterRowResize', editor._afterRowResizeCallback); editor.hot.addHook('afterColumnResize', editor._afterColumnResizeCallback); editor.hot.addHook('afterDestroy', editor._afterDestroyCallback); }, afterInit: (editor) => editor._componentRef.instance.afterInit?.(editor), beforeOpen: (editor, context) => { cleanupSubscriptions(editor); applyPropsToEditor(editor); editor._finishEditSubscription = editor._componentRef.instance.finishEdit.pipe(take(1)).subscribe(() => { editor.finishEditing(); }); editor._cancelEditSubscription = editor._componentRef.instance.cancelEdit.pipe(take(1)).subscribe(() => { editor.cancelChanges(); }); editor._componentRef.instance.beforeOpen?.(editor, context); }, afterOpen: (editor, event) => { editor._componentRef.instance.afterOpen?.(editor, event); }, onFocus: (editor) => editor._componentRef.instance.onFocus?.(editor), afterClose: (editor) => { resetEditorState(editor); editor._editorPlaceHolderRef?.changeDetectorRef.detectChanges(); editor._editorPlaceHolderRef?.instance.detachEditor(); editor._componentRef.instance.afterClose?.(editor); }, getValue: (editor) => editor._componentRef.instance.getValue(), setValue: (editor, value) => { editor.value = value; editor._componentRef.instance.setValue(value); editor._componentRef.changeDetectorRef.detectChanges(); }, }); /** * Creates the editor placeholder component. * @param editor The editor instance. * @param injector The environment injector from Angular. */ function createEditorPlaceholder(editor, injector) { if (!injector) { return; } editor._editorPlaceHolderRef = createComponent(CustomEditorPlaceholderComponent, { environmentInjector: injector, }); } /** * Applies properties to the custom Angular editor and editor placeholder. * Updates position, size, and cell context information. * @param editor The editor instance. */ function applyPropsToEditor(editor) { if (!editor._componentRef || !editor._editorPlaceHolderRef) { return; } editor._componentRef.setInput('originalValue', editor.originalValue); editor._componentRef.setInput('row', editor.row); editor._componentRef.setInput('column', editor.col); editor._componentRef.setInput('prop', editor.prop); editor._componentRef.setInput('cellProperties', editor.cellProperties); const rect = editor.hot.getCell(editor.row, editor.col)?.getBoundingClientRect(); editor._editorPlaceHolderRef.setInput('placeholderCustomClass', ''); editor._editorPlaceHolderRef.setInput('height', rect?.height ?? 0); editor._editorPlaceHolderRef.setInput('width', rect?.width ?? 0); editor._editorPlaceHolderRef.setInput('isVisible', true); editor._editorPlaceHolderRef.setInput('componentRef', editor._componentRef); editor._editorPlaceHolderRef.changeDetectorRef.detectChanges(); } /** * Resets the editor placeholder state. * Clears all positioning and visibility settings. * @param editor The editor instance. */ function resetEditorState(editor) { if (!editor._editorPlaceHolderRef) { return; } editor._editorPlaceHolderRef.setInput('top', undefined); editor._editorPlaceHolderRef.setInput('left', undefined); editor._editorPlaceHolderRef.setInput('height', undefined); editor._editorPlaceHolderRef.setInput('width', undefined); editor._editorPlaceHolderRef.setInput('isVisible', false); editor._editorPlaceHolderRef.setInput('componentRef', undefined); } /** * Cleans up existing subscriptions. * @param editor The editor instance. */ function cleanupSubscriptions(editor) { if (editor._finishEditSubscription) { editor._finishEditSubscription.unsubscribe(); editor._finishEditSubscription = undefined; } if (editor._cancelEditSubscription) { editor._cancelEditSubscription.unsubscribe(); editor._cancelEditSubscription = undefined; } } /** * Abstract base component for creating advanced custom cell renderer components for Handsontable. * * Extend this component and provide your own template to implement a custom renderer. * Unlike {@link HotCellRendererComponent}, this variant also accepts object and array values. * * @template TValue - The type of the component renderer. * @template TProps - The type of additional renderer properties. */ class HotCellRendererAdvancedComponent extends HotCellRendererBase { static RENDERER_MARKER = Symbol('HotCellRendererAdvancedComponent'); static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererAdvancedComponent, deps: null, target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.25", type: HotCellRendererAdvancedComponent, isStandalone: true, selector: "hot-cell-renderer-advanced", usesInheritance: true, ngImport: i0, template: `<!-- This is an abstract component. Extend this component and provide your own template. -->`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellRendererAdvancedComponent, decorators: [{ type: Component, args: [{ selector: 'hot-cell-renderer-advanced', template: `<!-- This is an abstract component. Extend this component and provide your own template. -->`, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }] }] }); /** * Abstract class representing an advanced Handsontable cell editor in Angular. * * Extend this class and decorate the subclass with `@Component()` to implement a custom editor. * Unlike {@link HotCellEditorComponent}, this variant also accepts object and array values * and provides additional lifecycle hooks and positioning options. */ class HotCellEditorAdvancedComponent extends HotCellEditorBase { static EDITOR_MARKER = Symbol('HotCellEditorAdvancedComponent'); /** Event triggered by Handsontable on focusing the editor. Available in advanced mode. */ onFocus(editor) { } /** The position of the editor in the DOM. Available in advanced mode. */ position = 'container'; /** The shortcuts available for the editor. Available in advanced mode. */ shortcuts; /** The group name for the shortcuts. Available in advanced mode. */ shortcutsGroup; /** Configuration object. Available in advanced mode. */ config; /** Lifecycle hook called after the editor is opened. Available in advanced mode. */ afterOpen(editor, event) { } /** Lifecycle hook called after the editor is closed. Available in advanced mode. */ afterClose(editor) { } /** Lifecycle hook called after the editor is initialized. Available in advanced mode. */ afterInit(editor) { } /** Lifecycle hook called before the editor is opened. Available in advanced mode. */ beforeOpen(editor, { row, col, prop, td, originalValue, cellProperties, }) { } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorAdvancedComponent, deps: null, target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.25", type: HotCellEditorAdvancedComponent, isStandalone: true, usesInheritance: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: HotCellEditorAdvancedComponent, decorators: [{ type: Directive }] }); const INVALID_RENDERER_WARNING = 'The provided renderer component was not recognized as a valid custom renderer. ' + 'It must either extend HotCellRendererComponent or be a valid TemplateRef. ' + 'Please ensure that your custom renderer is implemented correctly and imported from the proper source.'; const INVALID_ADVANCED_RENDERER_WARNING = 'The provided renderer component was not recognized as a valid custom renderer. ' + 'It must either extend HotCellRendererAdvancedComponent. ' + 'Please ensure that your custom renderer is implemented correctly and imported from the proper source.'; // Renderer component inputs, listed once at module scope so the per-cell render path can set them // without allocating a fresh key array (via Object.keys) on every cell of every render frame. const RENDERER_INPUT_KEYS = [ 'instance', 'td', 'row', 'col', 'prop', 'value', 'cellProperties', ]; /** * Type guard that checks if the given object is a TemplateRef. * * @param obj - The object to check. * @returns True if the object is a TemplateRef; otherwise, false. */ function isTemplateRef(obj) { return !!obj && typeof obj.createEmbeddedView === 'function'; } /** * Type guard to check if an object is an instance of HotCellRendererComponent. * * @param obj - The object to check. * @returns True if the object is a HotCellRendererComponent, false otherwise. */ function isHotCellRendererComponent(obj) { return obj?.RENDERER_MARKER === HotCellRendererComponent.RENDERER_MARKER; } /** * Type guard to check if an object is an instance of HotCellRendererAdvancedComponent. * * @param obj - The object to check. * @returns True if the object is a HotCellRendererAdvancedComponent, false otherwise. */ function isAdvancedHotCellRendererComponent(obj) { return obj?.RENDERER_MARKER === HotCellRendererAdvancedComponent.RENDERER_MARKER; } /** * Service for dynamically creating Angular components or templates as custom renderers for Handsontable. * * This service allows you to create a renderer function that wraps a given Angular component or TemplateRef * so that it can be used as a Handsontable renderer. * * @example * const customRenderer = dynamicComponentService.createRendererFromComponent(MyRendererComponent, { someProp: value }); * // Use customRenderer in your Handsontable configuration */ class DynamicComponentService { appRef; environmentInjector; // Track Angular component refs and embedded views keyed by the TD element they are attached to. // When a cell is re-rendered the previous component is destroyed before a new one is created. _tdComponentRefs = new WeakMap(); _tdEmbeddedViews = new WeakMap(); // Per-instance registries of every renderer ref/view currently attached to the application. // The WeakMaps above only allow per-TD lookup; these sets let us sweep refs whose TD was dropped // from Handsontable's virtual viewport (scrolling, updateData), which would otherwise stay // attached to ApplicationRef forever and leak both memory and change-detection work. // // The registries are scoped per Handsontable instance (not one global set) because this service // is a root singleton shared by every <hot-table>. A global sweep would scan the cells of every // table on the page on each `afterViewRender`, i.e. on every scroll frame of any one of them. // Keying by instance bounds each sweep to the cells of the table that actually re-rendered. _instanceComponentRefs = new WeakMap(); _instanceEmbeddedViews = new WeakMap(); // Instances we already wired the sweep hook into, so each instance is hooked at most once. _hookedInstances = new WeakSet(); constructor(appRef, environmentInjector) { this.appRef = appRef; this.environmentInjector = environmentInjector; } /** * Creates a custom renderer function for Handsontable from an Angular component or TemplateRef. * The generated renderer function will be used by Handsontable to render cell content. * * @param component - The Angular component type or TemplateRef to use as renderer. * @param componentProps - An object containing additional properties to use by the renderer. * @param register - If true, registers the renderer with Handsontable using the component's name. * @returns A renderer function that can be used in Handsontable's configuration. */ createRendererFromComponent(component, componentProps = {}, register = false) { let registered = false; return (instance, td, row, col, prop, value, cellProperties) => { const properties = { value, instance, td, row, col, prop, cellProperties, }; cellProperties.rendererProps = componentProps; baseRenderer.call(this, instance, td, row, col, prop, value, cellProperties); this.registerSweepHook(instance); if (isTemplateRef(component)) { // Embedded views carry a per-render context, so they are always rebuilt. this.replaceCellContent(instance, td); const embeddedView = this.attachTemplateToElement(component, td, properties); this.trackEmbeddedView(instance, td, embeddedView); } else if (isHotCellRendererComponent(component)) { this.renderComponent(td, component, properties); } else { console.warn(INVALID_RENDERER_WARNING); } if (register && !registered && isHotCellRendererComponent(component)) { Handsontable.renderers.registerRenderer(component.name, component); registered = true; } return td; }; } /** * Creates a custom renderer function using rendererFactory from Handsontable. * This is an alternative implementation that uses the factory pattern. * * @param component - The Angular component type to use as renderer. * @param componentProps - An object containing additional properties to use by the renderer. * @param register - If true, registers the renderer with Handsontable using the component's name. * @returns A renderer function that can be used in Handsontable's configuration. */ createRendererWithFactory(component, componentProps = {}, register = false) { let registered = false; return rendererFactory(({ instance, td, row, column, prop, value, cellProperties }) => { const properties = { value, instance, td, row, col: column, prop, cellProperties, }; cellProperties.rendererProps = componentProps; // Apply the base renderer so the TD gets the same base classes/attributes as the // createRendererFromComponent path (rendererFactory itself does not call it). baseRenderer.call(this, instance, td, row, column, prop, value, cellProperties); this.registerSweepHook(instance); if (isAdvancedHotCellRendererComponent(component)) { this.renderComponent(td, component, properties); } else { console.warn(INVALID_ADVANCED_RENDERER_WARNING); } if (register && !registered && isAdvancedHotCellRendererComponent(component)) { registerRenderer(component.name, component); registered = true; } }); } /** * Destroys all renderer components and embedded views attached to cells within a container element. * Must be called before destroying the Handsontable instance to prevent Angular component leaks. * * @param container - The root DOM element of the Handsontable instance. * @param instance - The Handsontable instance whose registries should be torn down. When omitted * (e.g. test stubs), only refs reachable through TDs still in the container are destroyed. */ cleanupContainer(container, instance) { const compRefs = instance ? this._instanceComponentRefs.get(instance) : undefined; const embViews = instance ? this._instanceEmbeddedViews.get(instance) : undefined; container.querySelectorAll('td').forEach((td) => { const compRef = this._tdComponentRefs.get(td); if (compRef) { this.destroyComponent(compRef); this._tdComponentRefs.delete(td); compRefs?.delete(compRef); } const embView = this._tdEmbeddedViews.get(td); if (embView) { this.destroyEmbeddedView(embView); this._tdEmbeddedViews.delete(td); embViews?.delete(embView); } }); // The loop above only reaches TDs still present in the container. Refs for cells already // dropped from the viewport (but not yet swept) would otherwise be orphaned once this // instance's afterViewRender hook is gone after destroy. Tear down whatever is left and drop // the per-instance registries so a repeated cleanup call is a no-op. (Entries removed in the // loop above are already gone from these sets, so nothing is destroyed twice.) compRefs?.forEach((ref) => this.destroyComponent(ref)); embViews?.forEach((view) => this.destroyEmbeddedView(view)); if (instance) { this._instanceComponentRefs.delete(instance); this._instanceEmbeddedViews.delete(instance); } } /** * Registers a one-time `afterViewRender` hook on the given Handsontable instance that sweeps * renderer refs whose TD is no longer connected to the document. Handsontable recycles a pool of * TD elements while virtualizing rows; cells that leave the viewport are never re-rendered, so * without this sweep their Angular components stay attached to ApplicationRef and leak. * * Guarded against instances that do not expose `addHook` (e.g. test stubs). * * @param instance - The Handsontable instance whose render cycle drives the sweep. */ registerSweepHook(instance) { if (this._hookedInstances.has(instance) || typeof instance?.addHook !== 'function') { return; } this._hookedInstances.add(instance); instance.addHook('afterViewRender', () => this.sweepDetachedViews(instance)); } /** * Destroys every renderer ref/view tracked for the given instance whose root node is no longer * attached to the document. * * @param instance - The Handsontable instance whose registries should be swept. */ sweepDetachedViews(instance) { const compRefs = this._instanceComponentRefs.get(instance); compRefs?.forEach((ref) => { if (!this.isViewConnected(ref.hostView)) { this.destroyComponent(ref); compRefs.delete(ref); } }); const embViews = this._instanceEmbeddedViews.get(instance); embViews?.forEach((view) => { if (!this.isViewConnected(view)) { this.destroyEmbeddedView(view); embViews.delete(view); } }); } /** * @returns True if any of the view's root nodes is still connected to the document. */ isViewConnected(view) { return view.rootNodes.some((node) => !!node?.isConnected); } /** * Destroys the renderer ref/view previously attached to a cell and clears the cell content, * so a fresh renderer can take over the TD without leaking the old one. * * @param instance - The Handsontable instance owning the cell, used to update its registries. * @param td - The table cell whose previous content should be torn down. */ replaceCellContent(instance, td) { const prevRef = this._tdComponentRefs.get(td); if (prevRef) { this.destroyComponent(prevRef); this._tdComponentRefs.delete(td); this._instanceComponentRefs.get(instance)?.delete(prevRef); } const prevView = this._tdEmbeddedViews.get(td); if (prevView) { this.destroyEmbeddedView(prevView); this._tdEmbeddedViews.delete(td); this._instanceEmbeddedViews.get(instance)?.delete(prevView); } td.innerHTML = ''; } /** * Tracks a component ref both by its TD (for fast replacement) and in the instance registry * (for sweeping and full teardown). */ trackComponentRef(instance, td, ref) { this._tdComponentRefs.set(td, ref); this.registryFor(this._instanceComponentRefs, instance).add(ref); } /** * Tracks an embedded view both by its TD (for fast replacement) and in the instance registry * (for sweeping and full teardown). */ trackEmbeddedView(instance, td, view) { this._tdEmbeddedViews.set(td, view); this.registryFor(this._instanceEmbeddedViews, instance).add(view); } /** * Returns the per-instance registry set for the given instance, creating it on first use. */ registryFor(registry, instance) { let set = registry.get(instance); if (!set) { set = new Set(); registry.set(instance, set); } return set; } /** * Attaches an embedded view created from a TemplateRef to a given DOM element. * * @param template - The TemplateRef to create an embedded view from. * @param tdEl - The target DOM element (a table cell) to which the view will be appended. * @param properties - Context object providing properties to be used within the template. * @returns The created EmbeddedViewRef so the caller can track and destroy it later. */ attachTemplateToElement(template, tdEl, properties) { const embeddedView = template.createEmbeddedView({ $implicit: properties.value, ...properties, }); embeddedView.detectChanges(); embeddedView.rootNodes.forEach((node) => { tdEl.appendChild(node); }); return embeddedView; } /** * Dynamically creates an Angular component of the given type. * * @param component - The Angular component type to be created. * @param rendererParameters - An object containing input properties to assign to the component instance. * @returns The ComponentRef of the dynamically created component. */ createComponent(component, rendererParameters) { const componentRef = createComponent(component, { environmentInjector: this.environmentInjector, }); this.applyInputs(componentRef, rendererParameters); componentRef.changeDetectorRef.detectChanges(); this.appRef.attachView(componentRef.hostView); return componentRef; } /** * Renders an Angular component into the given cell, recycling the component already attached to * the TD when it is of the same type. Handsontable recycles its pool of TD elements heavily while * virtualizing rows, so recreating an Angular component on every re-render would cause needless * teardown/instantiation churn and GC pressure. When the type matches we only refresh the inputs. * * @param td - The target table cell. * @param component - The renderer component type to render. * @param properties - The renderer parameters to feed as component inputs. */ renderComponent(td, component, properties) { const prevRef = this._tdComponentRefs.get(td); if (prevRef && prevRef.componentType === component && this.isViewConnected(prevRef.hostView)) { this.applyInputs(prevRef, properties); prevRef.changeDetectorRef.detectChanges(); return; } this.replaceCellContent(properties.instance, td); const componentRef = this.createComponent(component, properties); this.attachComponentToElement(componentRef, td); this.trackComponentRef(properties.instance, td, componentRef); } /** * Assigns every renderer parameter as an input on the given component ref. * * @param componentRef - The component ref whose inputs should be set. * @param rendererParameters - The renderer parameters to assign. */ applyInputs(componentRef, rendererParameters) { RENDERER_INPUT_KEYS.forEach((key) => { componentRef.setInput(key, rendererParameters[key]); }); } /** * Attaches a dynamically created component's view to a specified DOM container element. * * @param componentRef - The reference to the dynamically created component. * @param container - The target DOM element to which the component's root node will be appended. */ attachComponentToElement(componentRef, container) { componentRef.hostView.rootNodes.forEach((node) => { container.appendChild(node); }); } /** * Destroys a dynamically created component and detaches its view from the Angular application. * * Idempotent: a TD recycled after `sweepDetachedViews` already destroyed its ref still maps to that * stale ref in `_tdComponentRefs`, so the next render path may reach this with an already-destroyed * ref. Guarding on `destroyed` skips the redundant detach/destroy instead of relying on Angular's * internal no-op behaviour. * * @param componentRef - The reference to the component to be destroyed. */ destroyComponent(componentRef) { const hostView = componentRef.hostView; if (hostView.destroyed) { return; } this.appRef.detachView(hostView); componentRef.destroy(); } /** * Destroys an embedded view. Idempotent for the same reason as {@link destroyComponent}: a recycled * TD can still map to an already-destroyed view in `_tdEmbeddedViews`. * * @param view - The embedded view to destroy. */ destroyEmbeddedView(view) { if (view.destroyed) { return; } view.destroy(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicComponentService, deps: [{ token: i0.ApplicationRef }, { token: i0.EnvironmentInjector }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicComponentService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.25", ngImport: i0, type: DynamicComponentService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: i0.ApplicationRef }, { type: i0.EnvironmentInjector }] }); const AVAILABLE_HOOKS_SET = new Set(Handsontable.hooks.getRegistered()); const HOT_ZONE_WRAPPED = Symbol('hotZoneWrapped'); /** * Service to resolve and apply custom settings for Handsontable settings object. */ class HotSettingsResolver { dynamicComponentService; environmentInjector; ngZone; constructor(dynamicComponentService, environmentInjector, ngZone) { this.dynamicComponentService = dynamicComponentService; this.environmentInjector = environmentInjector; this.ngZone = ngZone; } /** * Applies custom settings to the provided GridSettings. * @param settings The original grid settings. * @param previousColumns The previously resolved columns (from the prior settings cycle). When * supplied, an editor component already created for a column whose editor type is unchanged is * recycled instead of being recreated, avoiding needless Angular component teardown/rebuild. * @returns The merged grid settings with custom settings applied. */ applyCustomSettings(settings, previousColumns) { // Shallow-clone the user settings (and each column) before mutating. Otherwise we would // write generated renderers/editors and `_editorComponentReference` straight onto the // caller's objects. When the same settings/columns are shared across two <hot-table> // instances, the second resolution would overwrite the first instance's editor refs, // leaking them and cross-wiring a single editor component between tables. const mergedSettings = { ...settings }; if (Array.isArray(mergedSettings.columns)) { mergedSettings.columns = mergedSettings.columns.map((column) => ({ ...column })); } this.updateColumnRendererForGivenCustomRenderer(mergedSettings); this.updateColumnEditorForGivenCustomEditor(mergedSettings, previousColumns); this.updateColumnValidatorForGivenCustomValidator(mergedSettings); this.wrapHooksInNgZone(mergedSettings); return mergedSettings; } /** * Ensures that hook callbacks in the provided grid settings run inside Angular's zone. * * @param settings The original grid settings. */ wrapHooksInNgZone(settings) { const ngZone = this.ngZone; // Iterate only the keys actually present in settings instead of all ~100 registered HOT hooks. Object.keys(settings).forEach((key) => { if (!AVAILABLE_HOOKS_SET.has(key)) { return; } const option = settings[key]; if (typeof option === 'functio