diff --git a/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts b/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts index accffb801c..918df3e7e4 100644 --- a/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts +++ b/packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts @@ -32,6 +32,7 @@ import { BuiltInChipContextMenuItem, ContextMenuItemConfig, ContextMenuItem, + DockviewModule, } from 'dockview-core'; import { AngularFrameworkComponentFactory } from '../utils/component-factory'; import { AngularRenderer } from '../utils/angular-renderer'; @@ -110,6 +111,8 @@ export class DockviewAngularComponent implements OnInit, OnDestroy, OnChanges { | { component: Type | TemplateRef } )[]; + @Input() modules?: DockviewModule[]; + @Output() ready = new EventEmitter(); @Output() didDrop = new EventEmitter(); @Output() willDrop = new EventEmitter(); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index c6e447404b..73b6b3ca23 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -65,7 +65,6 @@ import { onDidWindowResizeEnd, onDidWindowMoveEnd, toggleClass, - watchElementResize, } from '../dom'; import { DockviewFloatingGroupPanel } from './dockviewFloatingGroupPanel'; import { @@ -96,12 +95,66 @@ import { IEdgeGroupHost, } from './dockviewShell'; import { DockviewGroupPanelApi } from '../api/dockviewGroupPanelApi'; +import { ModuleRegistry } from './modules'; +import { IFloatingGroupHost } from './floatingGroupService'; +import { FloatingGroupModule } from './floatingGroupModule'; const DEFAULT_ROOT_OVERLAY_MODEL: DroptargetOverlayModel = { activationSize: { type: 'pixels', value: 10 }, size: { type: 'pixels', value: 20 }, }; +function getAnchoredBox(options?: FloatingGroupOptionsInternal): AnchoredBox { + if (options?.position) { + const result: any = {}; + + if ('left' in options.position) { + result.left = Math.max(options.position.left, 0); + } else if ('right' in options.position) { + result.right = Math.max(options.position.right, 0); + } else { + result.left = DEFAULT_FLOATING_GROUP_POSITION.left; + } + if ('top' in options.position) { + result.top = Math.max(options.position.top, 0); + } else if ('bottom' in options.position) { + result.bottom = Math.max(options.position.bottom, 0); + } else { + result.top = DEFAULT_FLOATING_GROUP_POSITION.top; + } + if (typeof options.width === 'number') { + result.width = Math.max(options.width, 0); + } else { + result.width = DEFAULT_FLOATING_GROUP_POSITION.width; + } + if (typeof options.height === 'number') { + result.height = Math.max(options.height, 0); + } else { + result.height = DEFAULT_FLOATING_GROUP_POSITION.height; + } + return result as AnchoredBox; + } + + return { + left: + typeof options?.x === 'number' + ? Math.max(options.x, 0) + : DEFAULT_FLOATING_GROUP_POSITION.left, + top: + typeof options?.y === 'number' + ? Math.max(options.y, 0) + : DEFAULT_FLOATING_GROUP_POSITION.top, + width: + typeof options?.width === 'number' + ? Math.max(options.width, 0) + : DEFAULT_FLOATING_GROUP_POSITION.width, + height: + typeof options?.height === 'number' + ? Math.max(options.height, 0) + : DEFAULT_FLOATING_GROUP_POSITION.height, + }; +} + function moveGroupWithoutDestroying(options: { from: DockviewGroupPanel; to: DockviewGroupPanel; @@ -227,6 +280,7 @@ export interface PopoutGroupChangePositionEvent { } export interface IDockviewComponent extends IBaseGrid { + readonly moduleRegistry: ModuleRegistry; readonly activePanel: IDockviewPanel | undefined; readonly totalPanels: number; readonly panels: IDockviewPanel[]; @@ -311,6 +365,7 @@ export class DockviewComponent private readonly nextGroupId = sequentialNumberGenerator(); private readonly _deserializer = new DefaultDockviewDeserialzier(this); private readonly _api: DockviewApi; + private readonly _moduleRegistry = new ModuleRegistry(); private _options: Exclude; private _watermark: IWatermarkRenderer | null = null; private readonly _themeClassnames: Classnames; @@ -418,7 +473,6 @@ export class DockviewComponent IDisposable >(); - private readonly _floatingGroups: DockviewFloatingGroupPanel[] = []; private readonly _popoutGroups: { window: PopoutWindow; popoutGroup: DockviewGroupPanel; @@ -483,8 +537,15 @@ export class DockviewComponent return this._api; } + get moduleRegistry(): ModuleRegistry { + return this._moduleRegistry; + } + get floatingGroups(): DockviewFloatingGroupPanel[] { - return this._floatingGroups; + return ( + this._moduleRegistry?.services.floatingGroupService + ?.floatingGroups ?? [] + ); } /** @@ -510,6 +571,28 @@ export class DockviewComponent this._options = options; + // Module registration: if no explicit modules provided, auto-register + // defaults for backward compatibility. When modules is provided, the + // caller controls exactly which modules are active. + if (options.modules) { + for (const module of options.modules) { + this._moduleRegistry.register(module); + } + } else { + if (!options.disableFloatingGroups) { + this._moduleRegistry.register(FloatingGroupModule); + } + } + + // Instantiate all registered module services with this component as host + const floatingGroupHost: IFloatingGroupHost = { + fireLayoutChange: () => this._bufferOnDidLayoutChange.fire(), + updateWatermark: () => this.updateWatermark(), + doSetGroupAndPanelActive: (group) => + this.doSetGroupAndPanelActive(group), + }; + this._moduleRegistry.initialize(floatingGroupHost); + this.popupService = new PopupService(this.element); this.contextMenuController = new ContextMenuController(this); this._themeClassnames = new Classnames(this.element); @@ -680,10 +763,7 @@ export class DockviewComponent this._bufferOnDidLayoutChange.fire(); }), Disposable.from(() => { - // iterate over a copy of the array since .dispose() mutates the original array - for (const group of [...this._floatingGroups]) { - group.dispose(); - } + this._moduleRegistry.services.floatingGroupService?.disposeAll(); // iterate over a copy of the array since .dispose() mutates the original array for (const group of [...this._popoutGroups]) { @@ -767,16 +847,10 @@ export class DockviewComponent super.setVisible(panel, visible); break; case 'floating': { - const item = this.floatingGroups.find( - (floatingGroup) => floatingGroup.group === panel + this._moduleRegistry.services.floatingGroupService?.setVisible( + panel, + visible ); - - if (item) { - item.overlay.setVisible(visible); - panel.api._onDidVisibilityChange.fire({ - isVisible: visible, - }); - } break; } case 'popout': @@ -945,13 +1019,12 @@ export class DockviewComponent break; case 'floating': case 'popout': - floatingBox = this._floatingGroups - .find( - (value) => - value.group.api.id === - itemToPopout.api.id - ) - ?.overlay.toJSON(); + floatingBox = + this._moduleRegistry.services.floatingGroupService + ?.findFloatingGroup( + itemToPopout as DockviewGroupPanel + ) + ?.overlay.toJSON(); this.removeGroup(referenceGroup); @@ -1140,6 +1213,16 @@ export class DockviewComponent item: DockviewPanel | DockviewGroupPanel, options?: FloatingGroupOptionsInternal ): void { + const floatingGroupService = + this._moduleRegistry.services.floatingGroupService; + + if (!floatingGroupService) { + throw new Error( + 'dockview: FloatingGroupModule is not registered. ' + + 'Either include FloatingGroupModule in the modules option or remove disableFloatingGroups.' + ); + } + let group: DockviewGroupPanel; if (item instanceof DockviewPanel) { @@ -1197,58 +1280,7 @@ export class DockviewComponent } } - function getAnchoredBox(): AnchoredBox { - if (options?.position) { - const result: any = {}; - - if ('left' in options.position) { - result.left = Math.max(options.position.left, 0); - } else if ('right' in options.position) { - result.right = Math.max(options.position.right, 0); - } else { - result.left = DEFAULT_FLOATING_GROUP_POSITION.left; - } - if ('top' in options.position) { - result.top = Math.max(options.position.top, 0); - } else if ('bottom' in options.position) { - result.bottom = Math.max(options.position.bottom, 0); - } else { - result.top = DEFAULT_FLOATING_GROUP_POSITION.top; - } - if (typeof options.width === 'number') { - result.width = Math.max(options.width, 0); - } else { - result.width = DEFAULT_FLOATING_GROUP_POSITION.width; - } - if (typeof options.height === 'number') { - result.height = Math.max(options.height, 0); - } else { - result.height = DEFAULT_FLOATING_GROUP_POSITION.height; - } - return result as AnchoredBox; - } - - return { - left: - typeof options?.x === 'number' - ? Math.max(options.x, 0) - : DEFAULT_FLOATING_GROUP_POSITION.left, - top: - typeof options?.y === 'number' - ? Math.max(options.y, 0) - : DEFAULT_FLOATING_GROUP_POSITION.top, - width: - typeof options?.width === 'number' - ? Math.max(options.width, 0) - : DEFAULT_FLOATING_GROUP_POSITION.width, - height: - typeof options?.height === 'number' - ? Math.max(options.height, 0) - : DEFAULT_FLOATING_GROUP_POSITION.height, - }; - } - - const anchoredBox = getAnchoredBox(); + const anchoredBox = getAnchoredBox(options); const overlay = new Overlay({ container: this.gridview.element, @@ -1268,72 +1300,10 @@ export class DockviewComponent DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE), }); - const el = group.element.querySelector('.dv-void-container'); - - if (!el) { - throw new Error('dockview: failed to find drag handle'); - } - - overlay.setupDrag(el, { - inDragMode: - typeof options?.inDragMode === 'boolean' - ? options.inDragMode - : false, + floatingGroupService.addFloatingGroup(group, overlay, { + inDragMode: options?.inDragMode, + skipActiveGroup: options?.skipActiveGroup, }); - - const floatingGroupPanel = new DockviewFloatingGroupPanel( - group, - overlay - ); - - const disposable = new CompositeDisposable( - group.api.onDidActiveChange((event) => { - if (event.isActive) { - overlay.bringToFront(); - } - }), - watchElementResize(group.element, (entry) => { - const { width, height } = entry.contentRect; - group.layout(width, height); // let the group know it's size is changing so it can fire events to the panel - }) - ); - - floatingGroupPanel.addDisposables( - overlay.onDidChange(() => { - // this is either a resize or a move - // to inform the panels .layout(...) the group with it's current size - // don't care about resize since the above watcher handles that - group.layout(group.width, group.height); - }), - overlay.onDidChangeEnd(() => { - this._bufferOnDidLayoutChange.fire(); - }), - group.onDidChange((event) => { - overlay.setBounds({ - height: event?.height, - width: event?.width, - }); - }), - { - dispose: () => { - disposable.dispose(); - - remove(this._floatingGroups, floatingGroupPanel); - group.model.location = { type: 'grid' }; - this.updateWatermark(); - }, - } - ); - - this._floatingGroups.push(floatingGroupPanel); - - group.model.location = { type: 'floating' }; - - if (!options?.skipActiveGroup) { - this.doSetGroupAndPanelActive(group); - } - - this.updateWatermark(); } private orthogonalize( @@ -1383,29 +1353,9 @@ export class DockviewComponent override updateOptions(options: Partial): void { super.updateOptions(options); - if ('floatingGroupBounds' in options) { - for (const group of this._floatingGroups) { - switch (options.floatingGroupBounds) { - case 'boundedWithinViewport': - group.overlay.minimumInViewportHeight = undefined; - group.overlay.minimumInViewportWidth = undefined; - break; - case undefined: - group.overlay.minimumInViewportHeight = - DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE; - group.overlay.minimumInViewportWidth = - DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE; - break; - default: - group.overlay.minimumInViewportHeight = - options.floatingGroupBounds?.minimumHeightWithinViewport; - group.overlay.minimumInViewportWidth = - options.floatingGroupBounds?.minimumWidthWithinViewport; - } - - group.overlay.setBounds(); - } - } + this._moduleRegistry?.services.floatingGroupService?.updateBounds( + options + ); this.updateDropTargetModel(options); @@ -1447,12 +1397,7 @@ export class DockviewComponent super.layout(width, height, forceResize); } - if (this._floatingGroups) { - for (const floating of this._floatingGroups) { - // ensure floting groups stay within visible boundaries - floating.overlay.setBounds(); - } - } + this._moduleRegistry?.services.floatingGroupService?.constrainBounds(); } private _layoutFromShell(width: number, height: number): void { @@ -1648,14 +1593,9 @@ export class DockviewComponent {} as { [key: string]: GroupviewPanelState } ); - const floats: SerializedFloatingGroup[] = this._floatingGroups.map( - (group) => { - return { - data: group.group.toJSON() as GroupPanelViewState, - position: group.overlay.toJSON(), - }; - } - ); + const floats: SerializedFloatingGroup[] = + this._moduleRegistry.services.floatingGroupService?.serialize() ?? + []; const popoutGroups: SerializedPopoutGroup[] = this._popoutGroups.map( (group) => { @@ -1994,9 +1934,7 @@ export class DockviewComponent () => void 0 ); - for (const floatingGroup of this._floatingGroups) { - floatingGroup.overlay.setBounds(); - } + this._moduleRegistry.services.floatingGroupService?.constrainBounds(); if (typeof activeGroup === 'string') { const panel = this.getPanel(activeGroup); @@ -2032,10 +1970,7 @@ export class DockviewComponent this._onDidRemoveGroup.fire(group); } - // iterate over a reassigned array since original array will be modified - for (const floatingGroup of [...this._floatingGroups]) { - floatingGroup.dispose(); - } + this._moduleRegistry.services.floatingGroupService?.disposeAll(); // fires clean-up events and clears the underlying HTML gridview. this.clear(); @@ -2482,9 +2417,9 @@ export class DockviewComponent const activePanel = this.activePanel; if (group.api.location.type === 'floating') { - const floatingGroup = this._floatingGroups.find( - (_) => _.group === group - ); + const floatingGroupService = + this._moduleRegistry.services.floatingGroupService; + const floatingGroup = floatingGroupService?.findFloatingGroup(group); if (floatingGroup) { if (!options?.skipDispose) { @@ -2493,8 +2428,7 @@ export class DockviewComponent this._onDidRemoveGroup.fire(group); } - remove(this._floatingGroups, floatingGroup); - floatingGroup.dispose(); + floatingGroupService!.removeFloatingGroup(group); if (!options?.skipActive && this._activeGroup === group) { const groups = Array.from(this._groups.values()); @@ -3016,15 +2950,15 @@ export class DockviewComponent this.gridview.removeView(getGridLocation(from.element)); break; case 'floating': { - const selectedFloatingGroup = this._floatingGroups.find( - (x) => x.group === from - ); - if (!selectedFloatingGroup) { + const removedFloating = + this._moduleRegistry.services.floatingGroupService?.removeFloatingGroup( + from + ); + if (!removedFloating) { throw new Error( 'dockview: failed to find floating group' ); } - selectedFloatingGroup.dispose(); break; } case 'popout': { @@ -3110,9 +3044,10 @@ export class DockviewComponent } else if (to.api.location.type === 'floating') { // For moves to floating locations, add as floating group // Get the position/size from the target floating group - const targetFloatingGroup = this._floatingGroups.find( - (x) => x.group === to - ); + const targetFloatingGroup = + this._moduleRegistry.services.floatingGroupService?.findFloatingGroup( + to + ); if (targetFloatingGroup) { const box = targetFloatingGroup.overlay.toJSON(); diff --git a/packages/dockview-core/src/dockview/floatingGroupModule.ts b/packages/dockview-core/src/dockview/floatingGroupModule.ts new file mode 100644 index 0000000000..42e1d8c739 --- /dev/null +++ b/packages/dockview-core/src/dockview/floatingGroupModule.ts @@ -0,0 +1,24 @@ +import { DockviewModule } from './modules'; +import { + FloatingGroupService, + IFloatingGroupHost, +} from './floatingGroupService'; + +/** + * Module that enables floating (detached) group panels. + * + * When registered, groups can be detached from the grid and floated + * as draggable/resizable overlays within the dockview container. + * + * This is auto-registered when no explicit `modules` option is provided, + * preserving backward compatibility. To disable floating groups, either + * set `disableFloatingGroups: true` or provide an explicit `modules` + * array that does not include this module. + */ +export const FloatingGroupModule: DockviewModule = { + moduleName: 'FloatingGroup', + services: { + floatingGroupService: (host: IFloatingGroupHost) => + new FloatingGroupService(host), + }, +}; diff --git a/packages/dockview-core/src/dockview/floatingGroupService.ts b/packages/dockview-core/src/dockview/floatingGroupService.ts new file mode 100644 index 0000000000..ffee41e6ab --- /dev/null +++ b/packages/dockview-core/src/dockview/floatingGroupService.ts @@ -0,0 +1,233 @@ +import { CompositeDisposable, IDisposable } from '../lifecycle'; +import { Overlay } from '../overlay/overlay'; +import { DockviewFloatingGroupPanel } from './dockviewFloatingGroupPanel'; +import { DockviewGroupPanel } from './dockviewGroupPanel'; +import { DockviewOptions } from './options'; +import { SerializedFloatingGroup } from './dockviewComponent'; +import { GroupPanelViewState } from './dockviewGroupPanelModel'; +import { remove } from '../array'; +import { watchElementResize } from '../dom'; +import { DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE } from '../constants'; + +/** + * Host interface that the FloatingGroupService needs from the component. + * This keeps the service decoupled from the full DockviewComponent. + */ +export interface IFloatingGroupHost { + /** Fire the buffered layout change event */ + fireLayoutChange(): void; + /** Update the watermark state */ + updateWatermark(): void; + /** Set the active group and panel */ + doSetGroupAndPanelActive(group: DockviewGroupPanel | undefined): void; +} + +/** + * Service interface for floating group management. + * + * Core code accesses this via `services.floatingGroupService?.method()`. + * When the FloatingGroupModule is not registered, all floating group + * functionality is unavailable. + */ +export interface IFloatingGroupService extends IDisposable { + /** All active floating groups */ + readonly floatingGroups: DockviewFloatingGroupPanel[]; + + /** + * Register a group+overlay pair as a floating group. + * Handles event wiring, disposable setup, and array management. + */ + addFloatingGroup( + group: DockviewGroupPanel, + overlay: Overlay, + options?: { inDragMode?: boolean; skipActiveGroup?: boolean } + ): DockviewFloatingGroupPanel; + + /** Remove a floating group from tracking. Returns the found panel or undefined. */ + removeFloatingGroup( + group: DockviewGroupPanel + ): DockviewFloatingGroupPanel | undefined; + + /** Find the floating group panel for a given group */ + findFloatingGroup( + group: DockviewGroupPanel + ): DockviewFloatingGroupPanel | undefined; + + /** Toggle visibility of a floating group's overlay */ + setVisible(group: DockviewGroupPanel, visible: boolean): void; + + /** Update viewport bounds on all floating groups from changed options */ + updateBounds(options: Partial): void; + + /** Re-constrain all floating groups within their viewport bounds */ + constrainBounds(): void; + + /** Serialize all floating groups to JSON */ + serialize(): SerializedFloatingGroup[]; + + /** Dispose all floating groups (used during component teardown and error recovery) */ + disposeAll(): void; +} + +/** + * Manages floating group panels — the array of floating groups, their + * lifecycle, event wiring, bounds, serialization, and disposal. + */ +export class FloatingGroupService implements IFloatingGroupService { + private readonly _floatingGroups: DockviewFloatingGroupPanel[] = []; + private readonly _host: IFloatingGroupHost; + + get floatingGroups(): DockviewFloatingGroupPanel[] { + return this._floatingGroups; + } + + constructor(host: IFloatingGroupHost) { + this._host = host; + } + + addFloatingGroup( + group: DockviewGroupPanel, + overlay: Overlay, + options?: { inDragMode?: boolean; skipActiveGroup?: boolean } + ): DockviewFloatingGroupPanel { + overlay.setupDrag( + group.element.querySelector('.dv-void-container') as HTMLElement, + { + inDragMode: + typeof options?.inDragMode === 'boolean' + ? options.inDragMode + : false, + } + ); + + const floatingGroupPanel = new DockviewFloatingGroupPanel( + group, + overlay + ); + + const disposable = new CompositeDisposable( + group.api.onDidActiveChange((event) => { + if (event.isActive) { + overlay.bringToFront(); + } + }), + watchElementResize(group.element, (entry) => { + const { width, height } = entry.contentRect; + group.layout(width, height); + }) + ); + + floatingGroupPanel.addDisposables( + overlay.onDidChange(() => { + group.layout(group.width, group.height); + }), + overlay.onDidChangeEnd(() => { + this._host.fireLayoutChange(); + }), + group.onDidChange((event) => { + overlay.setBounds({ + height: event?.height, + width: event?.width, + }); + }), + { + dispose: () => { + disposable.dispose(); + remove(this._floatingGroups, floatingGroupPanel); + group.model.location = { type: 'grid' }; + this._host.updateWatermark(); + }, + } + ); + + this._floatingGroups.push(floatingGroupPanel); + group.model.location = { type: 'floating' }; + + if (!options?.skipActiveGroup) { + this._host.doSetGroupAndPanelActive(group); + } + + this._host.updateWatermark(); + + return floatingGroupPanel; + } + + removeFloatingGroup( + group: DockviewGroupPanel + ): DockviewFloatingGroupPanel | undefined { + const floatingGroup = this._floatingGroups.find( + (_) => _.group === group + ); + if (floatingGroup) { + remove(this._floatingGroups, floatingGroup); + floatingGroup.dispose(); + } + return floatingGroup; + } + + findFloatingGroup( + group: DockviewGroupPanel + ): DockviewFloatingGroupPanel | undefined { + return this._floatingGroups.find((_) => _.group === group); + } + + setVisible(group: DockviewGroupPanel, visible: boolean): void { + const item = this._floatingGroups.find( + (floatingGroup) => floatingGroup.group === group + ); + if (item) { + item.overlay.setVisible(visible); + group.api._onDidVisibilityChange.fire({ isVisible: visible }); + } + } + + updateBounds(options: Partial): void { + if (!('floatingGroupBounds' in options)) { + return; + } + for (const group of this._floatingGroups) { + switch (options.floatingGroupBounds) { + case 'boundedWithinViewport': + group.overlay.minimumInViewportHeight = undefined; + group.overlay.minimumInViewportWidth = undefined; + break; + case undefined: + group.overlay.minimumInViewportHeight = + DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE; + group.overlay.minimumInViewportWidth = + DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE; + break; + default: + group.overlay.minimumInViewportHeight = + options.floatingGroupBounds?.minimumHeightWithinViewport; + group.overlay.minimumInViewportWidth = + options.floatingGroupBounds?.minimumWidthWithinViewport; + } + group.overlay.setBounds(); + } + } + + constrainBounds(): void { + for (const floating of this._floatingGroups) { + floating.overlay.setBounds(); + } + } + + serialize(): SerializedFloatingGroup[] { + return this._floatingGroups.map((group) => ({ + data: group.group.toJSON() as GroupPanelViewState, + position: group.overlay.toJSON(), + })); + } + + disposeAll(): void { + // iterate over a copy since .dispose() mutates the original array + for (const group of [...this._floatingGroups]) { + group.dispose(); + } + } + + dispose(): void { + this.disposeAll(); + } +} diff --git a/packages/dockview-core/src/dockview/modules.ts b/packages/dockview-core/src/dockview/modules.ts new file mode 100644 index 0000000000..0c56f95715 --- /dev/null +++ b/packages/dockview-core/src/dockview/modules.ts @@ -0,0 +1,104 @@ +/** + * Module system for dockview. + * + * Modules are feature bundles that register services into the dockview + * component. This allows features to be independently included or excluded, + * enabling tree-shaking and a clean split between community and enterprise + * packages. + * + * @see {@link DockviewModule} for the module contract + * @see {@link ModuleRegistry} for runtime registration + */ + +import { IFloatingGroupService } from './floatingGroupService'; + +/** + * A dockview module declares a named feature bundle with optional services, + * CSS dependencies, and module dependencies. + * + * Modules are registered at component creation time via the `modules` option + * in `DockviewOptions`. Services declared by a module are made available + * through the component's `ServiceCollection`. + */ +export interface DockviewModule { + /** Unique identifier for this module (e.g. 'FloatingGroup') */ + moduleName: string; + /** + * Map of service name to service factory function. + * Each factory receives the host component and returns a service instance. + * The returned service is registered in the ServiceCollection. + */ + services?: Record any>; + /** CSS file paths that this module requires */ + css?: string[]; + /** Other modules that must be registered before this one */ + dependsOn?: DockviewModule[]; +} + +/** + * Optional service slots that modules can fill. + * + * Core code accesses these via optional chaining (`services.floatingGroupService?.doThing()`). + * Slots are populated at runtime when the corresponding module is registered. + */ +export interface ServiceCollection { + floatingGroupService?: IFloatingGroupService; +} + +/** + * Registry that tracks which modules have been registered for a + * dockview component instance. Each `DockviewComponent` owns one registry. + */ +export class ModuleRegistry { + private readonly _modules = new Map(); + private readonly _services: ServiceCollection = {}; + + /** Read-only view of the service collection */ + get services(): ServiceCollection { + return this._services; + } + + /** + * Register a module and all of its transitive dependencies. + * Duplicate registrations (by moduleName) are silently ignored. + */ + register(module: DockviewModule): void { + if (this._modules.has(module.moduleName)) { + return; + } + + // Register dependencies first (depth-first) + if (module.dependsOn) { + for (const dep of module.dependsOn) { + this.register(dep); + } + } + + this._modules.set(module.moduleName, module); + } + + /** + * Initialize all registered modules by creating their service instances. + * Must be called after all modules are registered and before the component + * is used. The host is passed to service factory functions. + */ + initialize(host: any): void { + for (const module of this._modules.values()) { + if (module.services) { + for (const [name, factory] of Object.entries(module.services)) { + (this._services as any)[name] = factory(host); + } + } + } + } + + /** Check whether a module has been registered */ + has(moduleName: string): boolean { + return this._modules.has(moduleName); + } + + /** Get all registered module names */ + get registeredModules(): string[] { + return Array.from(this._modules.keys()); + } +} diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 172693321a..d42526db7a 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -17,6 +17,7 @@ import { Contraints } from '../gridview/gridviewPanel'; import { AcceptableEvent, IAcceptableEvent } from '../events'; import { DockviewTheme } from './theme'; import { ITabGroup } from './tabGroup'; +import { DockviewModule } from './modules'; export interface IHeaderActionsRenderer extends IDisposable { readonly element: HTMLElement; @@ -177,6 +178,14 @@ export interface DockviewOptions { createTabGroupChipComponent?: ( tabGroup: ITabGroup ) => ITabGroupChipRenderer; + /** + * Modules to register with this dockview instance. + * + * Modules provide optional feature bundles (e.g. floating groups, + * popout windows) that register services into the component. + * Pass an array of `DockviewModule` objects. + */ + modules?: DockviewModule[]; } export type TabAnimation = 'smooth' | 'default'; @@ -233,6 +242,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => { getTabContextMenuItems: undefined, getTabGroupChipContextMenuItems: undefined, createTabGroupChipComponent: undefined, + modules: undefined, }; return Object.keys(properties) as (keyof DockviewOptions)[]; diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index ca8ccd9d63..c0ac5d930b 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -77,6 +77,16 @@ export { } from './dockview/framework'; export * from './dockview/options'; +export { + DockviewModule, + ServiceCollection, + ModuleRegistry, +} from './dockview/modules'; +export { + IFloatingGroupService, + IFloatingGroupHost, +} from './dockview/floatingGroupService'; +export { FloatingGroupModule } from './dockview/floatingGroupModule'; export * from './dockview/theme'; export * from './dockview/dockviewPanel'; export {