This commit is contained in:
@@ -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%;
|
||||
}
|
||||
`;
|
||||
+14
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user