chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
rules: {
'no-console': 'off',
'react/no-deprecated': 'off',
'@flowgram.ai/e2e-data-testid': 'off',
'react/prop-types': 'off'
},
});
@@ -0,0 +1,57 @@
{
"name": "@flowgram.ai/panel-manager-plugin",
"version": "0.1.8",
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "exit 0",
"test:cov": "exit 0",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"inversify": "^6.0.1",
"clsx": "^1.1.1",
"nanoid": "^5.0.9",
"zustand": "^4.5.5",
"use-sync-external-store": "^1.6.0",
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/utils": "workspace:*"
},
"devDependencies": {
"react": "^18",
"react-dom": "^18",
"@types/react": "^18",
"@types/react-dom": "^18",
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"tsup": "^8.0.1",
"typescript": "^5.8.3"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const globalCSS = `
.gedit-flow-panel-layer-wrap * {
box-sizing: border-box;
}
.gedit-flow-panel-layer-wrap {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.gedit-flow-panel-layer-wrap-docked {
}
.gedit-flow-panel-layer-wrap-floating {
pointer-events: none;
}
.gedit-flow-panel-left-area {
width: 100%;
min-width: 0;
flex-grow: 0;
flex-shrink: 1;
display: flex;
flex-direction: column;
}
.gedit-flow-panel-right-area {
height: 100%;
flex-grow: 1;
flex-shrink: 0;
min-width: 0;
display: flex;
max-width: 100%;
}
.gedit-flow-panel-main-area {
position: relative;
overflow: hidden;
flex-grow: 0;
flex-shrink: 1;
width: 100%;
height: 100%;
}
.gedit-flow-panel-bottom-area {
flex-grow: 1;
flex-shrink: 0;
width: 100%;
min-height: 0;
}
.gedit-flow-panel-wrap {
pointer-events: auto;
overflow: auto;
position: relative;
}
.gedit-flow-panel-wrap.panel-horizontal {
height: 100%;
}
.gedit-flow-panel-wrap.panel-vertical {
width: 100%;
}
`;
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { PanelLayer, PanelLayerProps } from './panel-layer';
export type DockedPanelLayerProps = Omit<PanelLayerProps, 'mode'>;
export const DockedPanelLayer: React.FC<DockedPanelLayerProps> = (props) => (
<PanelLayer mode="docked" {...props} />
);
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { PanelLayer, type PanelLayerProps } from './panel-layer';
export { DockedPanelLayer, type DockedPanelLayerProps } from './docked-panel-layer';
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import clsx from 'clsx';
import { useGlobalCSS } from '../../hooks/use-global-css';
import { PanelArea } from './panel';
import { globalCSS } from './css';
export type PanelLayerProps = React.PropsWithChildren<{
/** 模式:悬浮|挤压 */
mode?: 'floating' | 'docked';
className?: string;
style?: React.CSSProperties;
}>;
export const PanelLayer: React.FC<PanelLayerProps> = ({
mode = 'floating',
className,
style,
children,
}) => {
useGlobalCSS({
cssText: globalCSS,
id: 'flow-panel-layer-css',
});
return (
<div
className={clsx(
'gedit-flow-panel-layer-wrap',
mode === 'docked' && 'gedit-flow-panel-layer-wrap-docked',
mode === 'floating' && 'gedit-flow-panel-layer-wrap-floating',
className
)}
style={style}
>
<div className="gedit-flow-panel-left-area">
<div className="gedit-flow-panel-main-area">{children}</div>
<div className="gedit-flow-panel-bottom-area">
<PanelArea area={mode === 'docked' ? 'docked-bottom' : 'bottom'} />
</div>
</div>
<div className="gedit-flow-panel-right-area">
<PanelArea area={mode === 'docked' ? 'docked-right' : 'right'} />
</div>
</div>
);
};
@@ -0,0 +1,128 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
const { useEffect, useState, useRef } = React;
import { clsx } from 'clsx';
import { Area } from '../../types';
import { PanelEntity } from '../../services/panel-factory';
import { usePanelManager } from '../../hooks/use-panel-manager';
import { usePanelStore } from '../../hooks/use-panel';
import { PanelContext } from '../../contexts';
const PanelItem: React.FC<{ panel: PanelEntity; hidden?: boolean }> = ({ panel, hidden }) => {
const panelManager = usePanelManager();
const ref = useRef<HTMLDivElement>(null);
const isHorizontal = ['right', 'docked-right'].includes(panel.area);
const { size, fullscreen } = usePanelStore((s) => ({
size: s.size,
fullscreen: s.fullscreen,
}));
const [layerSize, setLayerSize] = useState(size);
const currentSize = fullscreen ? layerSize : size;
const sizeStyle = isHorizontal ? { width: currentSize } : { height: currentSize };
const handleResize = (next: number) => {
let nextSize = next;
if (typeof panel.factory.maxSize === 'number' && nextSize > panel.factory.maxSize) {
nextSize = panel.factory.maxSize;
} else if (typeof panel.factory.minSize === 'number' && nextSize < panel.factory.minSize) {
nextSize = panel.factory.minSize;
}
panel.store.setState({ size: nextSize });
};
useEffect(() => {
/** The set size may be illegal and needs to be updated according to the real element rendered for the first time. */
if (ref.current && !fullscreen) {
const { width, height } = ref.current.getBoundingClientRect();
const realSize = isHorizontal ? width : height;
panel.store.setState({ size: realSize });
}
}, [fullscreen]);
useEffect(() => {
if (!fullscreen) {
return;
}
const layer = panel.layer;
if (!layer) {
return;
}
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setLayerSize(isHorizontal ? width : height);
});
observer.observe(layer);
return () => observer.disconnect();
}, [fullscreen]);
return (
<div
className={clsx(
'gedit-flow-panel-wrap',
isHorizontal ? 'panel-horizontal' : 'panel-vertical'
)}
key={panel.id}
ref={ref}
style={{
display: hidden ? 'none' : 'block',
...panel.factory.style,
...panel.config.style,
...sizeStyle,
}}
>
{panel.resizable &&
panelManager.config.resizeBarRender({
size,
direction: isHorizontal ? 'vertical' : 'horizontal',
onResize: handleResize,
})}
{panel.renderer}
</div>
);
};
export const PanelArea: React.FC<{ area: Area }> = ({ area }) => {
const panelManager = usePanelManager();
const [panels, setPanels] = useState(panelManager.getPanels(area));
useEffect(() => {
let pending: number;
function startTransition(fn: () => void) {
clearTimeout(pending);
pending = setTimeout(fn, 0);
}
const dispose = panelManager.onPanelsChange(() => {
const r: any = { ...React };
const start = r['startTransition'] as undefined | ((cb: () => void) => void);
if (typeof start === 'function') {
start(() => setPanels(panelManager.getPanels(area)));
} else {
startTransition(() => setPanels(panelManager.getPanels(area)));
}
});
return () => dispose.dispose();
}, []);
return (
<>
{panels.map((panel) => (
<PanelContext.Provider value={panel} key={panel.id}>
<PanelItem panel={panel} hidden={panel.keepDOM && !panel.visible} />
</PanelContext.Provider>
))}
</>
);
};
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
const { useRef, useState } = React;
interface Props {
size: number;
direction?: 'vertical' | 'horizontal';
onResize: (w: number) => void;
}
export const ResizeBar: React.FC<Props> = ({ onResize, size, direction }) => {
const currentPoint = useRef<null | number>(null);
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const isVertical = direction === 'vertical';
return (
<div
onMouseDown={(e) => {
currentPoint.current = isVertical ? e.clientX : e.clientY;
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
const mouseUp = () => {
currentPoint.current = null;
document.body.removeEventListener('mouseup', mouseUp);
document.body.removeEventListener('mousemove', mouseMove);
setIsDragging(false);
};
const mouseMove = (e: MouseEvent) => {
const delta = currentPoint.current! - (isVertical ? e.clientX : e.clientY);
onResize(size + delta);
};
document.body.addEventListener('mouseup', mouseUp);
document.body.addEventListener('mousemove', mouseMove);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
position: 'absolute',
top: 0,
left: 0,
zIndex: 999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'auto',
...(isVertical
? {
cursor: 'ew-resize',
height: '100%',
marginLeft: -5,
width: 10,
}
: {
cursor: 'ns-resize',
width: '100%',
marginTop: -5,
height: 10,
}),
}}
>
<div
style={{
...(isVertical
? {
width: 3,
height: '100%',
}
: {
height: 3,
width: '100%',
}),
backgroundColor: isDragging || isHovered ? 'var(--g-playground-line)' : 'transparent',
}}
/>
</div>
);
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createContext } from 'react';
import type { PanelEntity } from './services/panel-factory';
export const PanelContext = createContext({} as PanelEntity);
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator } from '@flowgram.ai/core';
import {
PanelEntityFactory,
PanelEntity,
PanelEntityFactoryConstant,
PanelEntityConfigConstant,
} from './services/panel-factory';
import { defineConfig } from './services/panel-config';
import {
PanelManager,
PanelManagerConfig,
PanelLayer,
PanelRestore,
PanelRestoreImpl,
} from './services';
export const createPanelManagerPlugin = definePluginCreator<Partial<PanelManagerConfig>>({
onBind: ({ bind }, opt) => {
bind(PanelManager).to(PanelManager).inSingletonScope();
bind(PanelRestore).to(PanelRestoreImpl).inSingletonScope();
bind(PanelManagerConfig).toConstantValue(defineConfig(opt));
bind(PanelEntityFactory).toFactory(
(context) =>
({
factory,
config,
}: {
factory: PanelEntityFactoryConstant;
config: PanelEntityConfigConstant;
}) => {
const container = context.container.createChild();
container.bind(PanelEntityFactoryConstant).toConstantValue(factory);
container.bind(PanelEntityConfigConstant).toConstantValue(config);
const panel = container.resolve(PanelEntity);
panel.init();
return panel;
}
);
},
onInit(ctx) {
ctx.playground.registerLayer(PanelLayer);
const panelManager = ctx.container.get<PanelManager>(PanelManager);
panelManager.init();
},
});
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
interface UseGlobalCSSOptions {
cssText: string;
id: string;
cleanup?: boolean;
}
export const useGlobalCSS = ({ cssText, id, cleanup }: UseGlobalCSSOptions) => {
useEffect(() => {
/** SSR safe */
if (typeof document === 'undefined') return;
if (document.getElementById(id)) return;
const style = document.createElement('style');
style.id = id;
style.textContent = cssText;
document.head.appendChild(style);
return () => {
const existing = document.getElementById(id);
if (existing && cleanup) existing.remove();
};
}, [id]);
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useService } from '@flowgram.ai/core';
import { PanelManager } from '../services/panel-manager';
export const usePanelManager = () => useService<PanelManager>(PanelManager);
@@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { shallow } from 'zustand/shallow';
import { PanelEntityState } from '../services/panel-factory';
import { PanelContext } from '../contexts';
export const usePanel = () => useContext(PanelContext);
export const usePanelStore = <T>(selector: (s: PanelEntityState) => T) => {
const panel = usePanel();
return useStoreWithEqualityFn(panel.store, selector, shallow);
};
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/** create plugin function */
export { createPanelManagerPlugin } from './create-panel-manager-plugin';
/** services */
export { PanelManager, PanelRestore, type PanelManagerConfig } from './services';
/** react hooks */
export { usePanelManager } from './hooks/use-panel-manager';
export { usePanel } from './hooks/use-panel';
export { DockedPanelLayer, type DockedPanelLayerProps } from './components/panel-layer';
export { ResizeBar } from './components/resize-bar';
/** types */
export type { Area, PanelFactory } from './types';
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { PanelManager } from './panel-manager';
export { PanelManagerConfig } from './panel-config';
export { PanelLayer } from './panel-layer';
export { PanelRestore, PanelRestoreImpl } from './panel-restore';
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { PluginContext } from '@flowgram.ai/core';
import type { PanelFactory, PanelConfig } from '../types';
import { ResizeBar } from '../components/resize-bar';
import type { PanelLayerProps } from '../components/panel-layer';
export interface PanelManagerConfig {
factories: PanelFactory<any>[];
right: PanelConfig;
bottom: PanelConfig;
dockedRight: PanelConfig;
dockedBottom: PanelConfig;
/** Resizable, and multi-panel options mutually exclusive */
autoResize: boolean;
layerProps: PanelLayerProps;
resizeBarRender: ({
size,
}: {
size: number;
direction?: 'vertical' | 'horizontal';
onResize: (size: number) => void;
}) => React.ReactNode;
getPopupContainer: (ctx: PluginContext) => HTMLElement; // default playground.node.parentElement
}
export const PanelManagerConfig = Symbol('PanelManagerConfig');
export const defineConfig = (config: Partial<PanelManagerConfig>) => {
const defaultConfig: PanelManagerConfig = {
right: {
max: 1,
},
bottom: {
max: 1,
},
dockedRight: {
max: 1,
},
dockedBottom: {
max: 1,
},
factories: [],
autoResize: true,
layerProps: {},
resizeBarRender: ResizeBar,
getPopupContainer: (ctx: PluginContext) => ctx.playground.node.parentNode as HTMLElement,
};
return {
...defaultConfig,
...config,
};
};
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { createStore, StoreApi } from 'zustand/vanilla';
import { nanoid } from 'nanoid';
import { inject, injectable } from 'inversify';
import type { PanelFactory, PanelEntityConfig, Area } from '../types';
import { PanelRestore } from './panel-restore';
import { PanelManagerConfig } from './panel-config';
import { merge } from '../utils';
export const PanelEntityFactory = Symbol('PanelEntityFactory');
export type PanelEntityFactory = (options: {
factory: PanelEntityFactoryConstant;
config: PanelEntityConfigConstant;
}) => PanelEntity;
export const PanelEntityFactoryConstant = Symbol('PanelEntityFactoryConstant');
export type PanelEntityFactoryConstant = PanelFactory<any>;
export const PanelEntityConfigConstant = Symbol('PanelEntityConfigConstant');
export type PanelEntityConfigConstant = PanelEntityConfig<any> & {
area: Area;
};
const PANEL_SIZE_DEFAULT = 400;
export interface PanelEntityState {
size: number;
fullscreen: boolean;
visible: boolean;
}
@injectable()
export class PanelEntity {
@inject(PanelRestore) restore: PanelRestore;
/** 面板工厂 */
@inject(PanelEntityFactoryConstant) public factory: PanelEntityFactoryConstant;
@inject(PanelEntityConfigConstant) public config: PanelEntityConfigConstant;
@inject(PanelManagerConfig) readonly globalConfig: PanelManagerConfig;
private initialized = false;
/** 实例唯一标识 */
id: string = nanoid();
/** 渲染缓存 */
node: React.ReactNode = null;
store: StoreApi<PanelEntityState>;
get area() {
return this.config.area;
}
get mode() {
return this.config.area.startsWith('docked') ? 'docked' : 'floating';
}
get key() {
return this.factory.key;
}
get renderer() {
if (!this.node) {
this.node = this.factory.render(this.config.props);
}
return this.node;
}
get fullscreen() {
return this.store.getState().fullscreen;
}
set fullscreen(next: boolean) {
this.store.setState({ fullscreen: next });
}
get resizable() {
if (this.fullscreen) {
return false;
}
return this.factory.resize !== undefined ? this.factory.resize : this.globalConfig.autoResize;
}
get keepDOM() {
return this.factory.keepDOM;
}
get visible() {
return this.store.getState().visible;
}
set visible(next: boolean) {
this.store.setState({ visible: next });
}
get layer() {
return document.querySelector(
this.mode ? '.gedit-flow-panel-layer-wrap-docked' : '.gedit-flow-panel-layer-wrap-floating'
);
}
init() {
if (this.initialized) {
return;
}
this.initialized = true;
const cache = this.restore.restore<PanelEntityState>(this.key);
const initialState = merge<PanelEntityState>(
{
size: this.config.defaultSize,
fullscreen: this.config.fullscreen,
},
cache ? cache : {},
{
size: this.factory.defaultSize || PANEL_SIZE_DEFAULT,
fullscreen: this.factory.fullscreen || false,
...(this.factory.keepDOM ? { visible: true } : {}),
}
);
this.store = createStore<PanelEntityState>(() => initialState);
}
mergeState() {}
dispose() {
this.restore.store(this.key, this.store.getState());
}
}
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import ReactDOM from 'react-dom';
import { createElement } from 'react';
import { injectable, inject } from 'inversify';
import { domUtils, Disposable } from '@flowgram.ai/utils';
import { Layer, PluginContext } from '@flowgram.ai/core';
import { PanelLayer as PanelLayerComp } from '../components/panel-layer';
import { PanelManagerConfig } from './panel-config';
@injectable()
export class PanelLayer extends Layer {
@inject(PanelManagerConfig) private readonly panelConfig: PanelManagerConfig;
@inject(PluginContext) private readonly pluginContext: PluginContext;
readonly panelRoot = domUtils.createDivWithClass('gedit-flow-panel-layer');
layout: JSX.Element | null = null;
onReady(): void {
this.panelConfig.getPopupContainer(this.pluginContext).appendChild(this.panelRoot);
this.toDispose.push(
Disposable.create(() => {
// Remove from PopupContainer
this.panelRoot.remove();
})
);
const commonStyle = {
pointerEvents: 'none',
width: '100%',
height: '100%',
position: 'absolute',
left: 0,
top: 0,
zIndex: 100,
};
domUtils.setStyle(this.panelRoot, commonStyle);
}
render(): JSX.Element {
if (!this.layout) {
const { children, ...layoutProps } = this.panelConfig.layerProps;
this.layout = createElement(PanelLayerComp, layoutProps, children);
}
return ReactDOM.createPortal(this.layout, this.panelRoot);
}
}
@@ -0,0 +1,132 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable, inject } from 'inversify';
import { Emitter } from '@flowgram.ai/utils';
import { PanelManagerConfig } from './panel-config';
import type { Area, PanelEntityConfig, PanelFactory } from '../types';
import { PanelEntity, PanelEntityFactory } from './panel-factory';
@injectable()
export class PanelManager {
@inject(PanelManagerConfig) readonly config: PanelManagerConfig;
@inject(PanelEntityFactory) readonly createPanel: PanelEntityFactory;
readonly panelRegistry = new Map<string, PanelFactory<any>>();
private panels = new Map<string, PanelEntity>();
private onPanelsChangeEvent = new Emitter<void>();
public onPanelsChange = this.onPanelsChangeEvent.event;
init() {
this.config.factories.forEach((factory) => this.register(factory));
}
/** registry panel factory */
register<T extends any>(factory: PanelFactory<T>) {
this.panelRegistry.set(factory.key, factory);
}
/** open panel */
public open(key: string, area: Area = 'right', options?: PanelEntityConfig) {
const factory = this.panelRegistry.get(key);
if (!factory) {
return;
}
const sameKeyPanels = this.getPanels(area).filter((p) => p.key === key);
if (factory.keepDOM && sameKeyPanels.length) {
const [panel] = sameKeyPanels;
// move to last
this.panels.delete(panel.id);
this.panels.set(panel.id, panel);
panel.visible = true;
} else {
if (!factory.allowDuplicates && sameKeyPanels.length) {
sameKeyPanels.forEach((p) => this.remove(p.id));
}
const panel = this.createPanel({
factory,
config: {
area,
...options,
},
});
this.panels.set(panel.id, panel);
}
this.trim(area);
this.onPanelsChangeEvent.fire();
}
/** close panel */
public close(key?: string) {
const panels = this.getPanels();
const closedPanels = key ? panels.filter((p) => p.key === key) : panels;
closedPanels.forEach((panel) => {
this.remove(panel.id);
});
this.onPanelsChangeEvent.fire();
}
private trim(area: Area) {
/** 1. general panel; 2. keepDOM visible panel */
const panels = this.getPanels(area).filter((p) => !p.keepDOM || p.visible);
const areaConfig = this.getAreaConfig(area);
while (panels.length > areaConfig.max) {
const removed = panels.shift();
if (removed) {
this.remove(removed.id);
}
}
}
private remove(id: string) {
const panel = this.panels.get(id);
if (!panel) {
return;
}
if (panel.keepDOM) {
panel.visible = false;
} else {
panel.dispose();
this.panels.delete(id);
}
}
getPanels(area?: Area) {
const panels: PanelEntity[] = [];
this.panels.forEach((panel) => {
if (!area || panel.area === area) {
panels.push(panel);
}
});
return panels;
}
getAreaConfig(area: Area) {
switch (area) {
case 'docked-bottom':
return this.config.dockedBottom;
case 'docked-right':
return this.config.dockedRight;
case 'bottom':
return this.config.bottom;
case 'right':
default:
return this.config.right;
}
}
dispose() {
this.onPanelsChangeEvent.dispose();
}
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { injectable } from 'inversify';
export const PanelRestore = Symbol('PanelRestore');
export interface PanelRestore {
store: (k: string, v: any) => void;
restore: <T>(k: string) => T | undefined;
}
@injectable()
export class PanelRestoreImpl implements PanelRestore {
map = new Map<string, any>();
store(k: string, v: any) {
this.map.set(k, v);
}
restore<T>(k: string): T | undefined {
return this.map.get(k) as T;
}
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export type Area = 'right' | 'bottom' | 'docked-right' | 'docked-bottom';
export interface PanelConfig {
/** max panel */
max: number;
}
export interface PanelFactory<T extends any> {
key: string;
defaultSize: number;
fullscreen?: boolean;
maxSize?: number;
minSize?: number;
style?: React.CSSProperties;
/** Allows multiple panels with the same key to be rendered simultaneously */
allowDuplicates?: boolean;
resize?: boolean;
keepDOM?: boolean;
render: (props: T) => React.ReactNode;
}
export interface PanelEntityConfig<T extends any = any> {
defaultSize?: number;
fullscreen?: boolean;
style?: React.CSSProperties;
props?: T;
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const merge = <T>(...objs: Partial<T>[]) => {
const result: any = {};
for (const obj of objs) {
if (!obj || typeof obj !== 'object') continue;
for (const key of Object.keys(obj)) {
const value = (obj as any)[key];
if (result[key] === undefined) {
result[key] = value;
}
}
}
return result as T;
};
@@ -0,0 +1,8 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.base.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["./src"],
"exclude": ["node_modules"]
}