diff --git a/components/_style.ts b/components/_style.ts
index 51612f96..6af5d045 100644
--- a/components/_style.ts
+++ b/components/_style.ts
@@ -61,3 +61,4 @@ import './input-file/style';
import './breadcrumb/style';
import './text-highlight/style';
import './link/style';
+import './floating-pane/style';
diff --git a/components/_util/getPrefixStorage.ts b/components/_util/getPrefixStorage.ts
new file mode 100644
index 00000000..94cbc25b
--- /dev/null
+++ b/components/_util/getPrefixStorage.ts
@@ -0,0 +1,5 @@
+const prefixStorage = 'fes-storage';
+
+export default function getPrefixStorage(suffix: string) {
+ return suffix ? `${prefixStorage}-${suffix}` : prefixStorage;
+}
diff --git a/components/components.ts b/components/components.ts
index f3d655c1..b6cf5f19 100644
--- a/components/components.ts
+++ b/components/components.ts
@@ -59,3 +59,4 @@ export * from './input-file';
export * from './breadcrumb';
export * from './text-highlight';
export * from './link';
+export * from './floating-pane';
diff --git a/components/floating-pane/floating-pane.tsx b/components/floating-pane/floating-pane.tsx
new file mode 100644
index 00000000..cb56400f
--- /dev/null
+++ b/components/floating-pane/floating-pane.tsx
@@ -0,0 +1,153 @@
+import {
+ Teleport,
+ Transition,
+ computed,
+ defineComponent,
+ nextTick,
+ ref,
+ watch,
+} from 'vue';
+import { isNumber } from 'lodash-es';
+import { useStorage } from '@vueuse/core';
+import getPrefixCls from '../_util/getPrefixCls';
+import { CloseOutlined } from '../icon';
+import { useTheme } from '../_theme/useTheme';
+import { useConfig } from '../config-provider';
+import getPrefixStorage from '../_util/getPrefixStorage';
+import { floatingPaneProps } from './props';
+import { useDrag } from './useDrag';
+
+const prefixCls = getPrefixCls('floating-pane');
+const UPDATE_VISIBLE_EVENT = 'update:visible';
+const AFTER_ENTER_EVENT = 'after-enter';
+const AFTER_LEAVE_EVENT = 'after-leave';
+
+const FloatingPane = defineComponent({
+ name: 'FFloatingPane',
+ props: floatingPaneProps,
+ emits: [
+ UPDATE_VISIBLE_EVENT,
+ AFTER_ENTER_EVENT,
+ AFTER_LEAVE_EVENT,
+ ],
+ setup(props, ctx) {
+ useTheme();
+ const innerVisible = ref(false);
+ watch(
+ () => props.visible,
+ () => {
+ nextTick(() => {
+ innerVisible.value = props.visible;
+ });
+ },
+ { immediate: true },
+ );
+ const config = useConfig();
+ const getContainer = computed(
+ () => props.getContainer || config.getContainer?.value,
+ );
+
+ function handleCancel() {
+ ctx.emit(UPDATE_VISIBLE_EVENT, false);
+ }
+
+ function handleTransitionAfterEnter(el: Element) {
+ ctx.emit(AFTER_ENTER_EVENT, el);
+ }
+ function handleTransitionAfterLeave(el: Element) {
+ ctx.emit(AFTER_LEAVE_EVENT, el);
+ }
+
+ const hasHeader = computed(() => ctx.slots.title || props.title);
+
+ function getHeader() {
+ const closeJsx = (
+
+
+
+ );
+ if (!hasHeader.value) {
+ return closeJsx;
+ }
+ const header = ctx.slots.title?.() || props.title;
+ return (
+
+ );
+ }
+
+ const transform = props.cachePrePosition
+ ? useStorage<{
+ offsetX: number;
+ offsetY: number;
+ }>(getPrefixStorage('floating-pane'), {
+ offsetX: 0,
+ offsetY: 0,
+ }, window[props.cachePrePosition])
+ : ref({
+ offsetX: 0,
+ offsetY: 0,
+ });
+ const styles = computed(() => {
+ const { offsetX, offsetY }
+ = transform.value;
+ return {
+ zIndex: props.zIndex,
+ width: isNumber(props.width) ? `${props.width}px` : props.width,
+ ...props.defaultPosition,
+ transform: `translate(${offsetX}px, ${offsetY}px)`,
+ };
+ });
+
+ const { handleMouseDown } = useDrag(transform);
+
+ const getBody = () => {
+ return (
+
+ {ctx.slots.default?.()}
+
+ );
+ };
+
+ const showDom = computed(
+ () =>
+ (props.displayDirective === 'if' && innerVisible.value)
+ || props.displayDirective === 'show',
+ );
+
+ const wrapperClass = computed(() => {
+ return [`${prefixCls}-container`, props.contentClass].filter(Boolean);
+ });
+
+ return () => (
+
+
+
+ {showDom.value && (
+
+ {getHeader()}
+ {getBody()}
+
+ )}
+
+
+
+ );
+ },
+});
+
+export default FloatingPane;
diff --git a/components/floating-pane/index.ts b/components/floating-pane/index.ts
new file mode 100644
index 00000000..fa81a94e
--- /dev/null
+++ b/components/floating-pane/index.ts
@@ -0,0 +1,11 @@
+import { withInstall } from '../_util/withInstall';
+import type { SFCWithInstall } from '../_util/interface';
+import FloatingPane from './floating-pane';
+
+type ModalType = SFCWithInstall;
+
+export { floatingPaneProps } from './props';
+export type { FloatingPaneProps } from './props';
+export const FFloatingPane = withInstall(FloatingPane as ModalType);
+
+export default FFloatingPane;
diff --git a/components/floating-pane/props.ts b/components/floating-pane/props.ts
new file mode 100644
index 00000000..d5ecc0c3
--- /dev/null
+++ b/components/floating-pane/props.ts
@@ -0,0 +1,47 @@
+import type { ComponentObjectPropsOptions, PropType, VNode, VNodeChild } from 'vue';
+import type { ExtractPublicPropTypes } from '../_util/interface';
+
+export interface PanePosition {
+ top?: string;
+ right?: string;
+ bottom?: string;
+ left?: string;
+}
+
+// 通用的属性
+export const floatingPaneProps = {
+ visible: Boolean,
+ displayDirective: {
+ type: String as PropType<'show' | 'if'>,
+ default: 'show',
+ },
+ title: String as PropType VNodeChild)>,
+ width: {
+ type: [String, Number] as PropType,
+ default: 520,
+ },
+ zIndex: {
+ type: Number,
+ default: 3000,
+ },
+ defaultPosition: {
+ type: Object as PropType,
+ default(): PanePosition {
+ return {
+ top: '50px',
+ right: '50px',
+ };
+ },
+ },
+ cachePrePosition: {
+ type: String as PropType<'sessionStorage' | 'localStorage'>,
+ default: 'localStorage',
+ },
+ getContainer: {
+ type: Function as PropType<() => HTMLElement>,
+ },
+ // 内容外层类名
+ contentClass: String,
+} as const satisfies ComponentObjectPropsOptions;
+
+export type FloatingPaneProps = ExtractPublicPropTypes;
diff --git a/components/floating-pane/style/index.less b/components/floating-pane/style/index.less
new file mode 100644
index 00000000..0b12068b
--- /dev/null
+++ b/components/floating-pane/style/index.less
@@ -0,0 +1,83 @@
+@import '../../style/themes/index';
+@import '../../style/mixins/index';
+
+@modal-prefix-cls: ~'@{cls-prefix}-floating-pane';
+
+.@{modal-prefix-cls} {
+ .default();
+ .text();
+
+ &-container {
+ position: fixed;
+ box-sizing: border-box;
+ background: var(--f-body-bg-color);
+ border-radius: var(--f-border-radius-base);
+ box-shadow: @shadow-down;
+ }
+
+ &-header {
+ position: relative;
+
+ --f-modal-header-icon-color: inherit;
+ display: flex;
+ align-items: center;
+ padding: @padding-md @padding-sm;
+ color: var(--f-head-color);
+ font-weight: @font-weight-medium;
+ font-size: @font-size-head;
+ border-bottom: var(--f-border-width-base) var(--f-border-style-base) var(--f-border-color-base);
+
+ .@{modal-prefix-cls}-icon {
+ display: flex;
+ align-items: center;
+ padding-right: @padding-sm;
+ color: var(--f-modal-header-icon-color);
+ font-size: @font-size-title;
+ }
+
+ .@{modal-prefix-cls}-status-info {
+ --f-modal-header-icon-color: var(--f-primary-color);
+ }
+
+ .@{modal-prefix-cls}-status-success {
+ --f-modal-header-icon-color: var(--f-success-color);
+ }
+
+ .@{modal-prefix-cls}-status-error {
+ --f-modal-header-icon-color: var(--f-danger-color);
+ }
+
+ .@{modal-prefix-cls}-status-confirm,
+ .@{modal-prefix-cls}-status-warning {
+ --f-modal-header-icon-color: var(--f-warning-color);
+ }
+ }
+
+ &-body {
+ padding: @padding-xs 0;
+ color: var(--f-sub-head-color);
+ font-size: var(--f-font-size-base);
+ }
+
+ &-close {
+ position: absolute;
+ top: auto;
+ right: 0;
+ padding: 0 @padding-sm;
+ color: var(--f-sub-head-color);
+ font-size: @font-size-head;
+ line-height: 0;
+ cursor: pointer;
+ }
+
+ &-fade-leave-active,
+ &-fade-enter-active {
+ transition: all @animation-duration-slow @ease-base-out;
+ }
+
+ &-fade-leave-to,
+ &-fade-enter-from {
+ transform: scale(0);
+ opacity: 0;
+ }
+}
diff --git a/components/floating-pane/style/index.ts b/components/floating-pane/style/index.ts
new file mode 100644
index 00000000..ed51d175
--- /dev/null
+++ b/components/floating-pane/style/index.ts
@@ -0,0 +1,2 @@
+import '../../style';
+import './index.less';
diff --git a/components/floating-pane/useDrag.ts b/components/floating-pane/useDrag.ts
new file mode 100644
index 00000000..1e67cc1c
--- /dev/null
+++ b/components/floating-pane/useDrag.ts
@@ -0,0 +1,56 @@
+import type { Ref } from 'vue';
+import { throttle } from 'lodash-es';
+import { useEventListener } from '@vueuse/core';
+
+export const useDrag = (
+ transform: Ref<{
+ offsetX: number;
+ offsetY: number;
+ }>,
+) => {
+ let isMouseDown = false;
+
+ let startX: number;
+ let startY: number;
+ let imgOffsetX: number;
+ let imgOffsetY: number;
+
+ const handleMouseDown = (event: MouseEvent) => {
+ // 取消默认图片拖拽的行为
+ event.preventDefault();
+ isMouseDown = true;
+ // 存储鼠标按下的偏移量和事件发生坐标
+ const { offsetX, offsetY } = transform.value;
+ startX = event.pageX;
+ startY = event.pageY;
+ imgOffsetX = offsetX;
+ imgOffsetY = offsetY;
+ };
+
+ const handleDrag = throttle((event: MouseEvent) => {
+ transform.value = {
+ ...transform.value,
+ offsetX: imgOffsetX + event.pageX - startX,
+ offsetY: imgOffsetY + event.pageY - startY,
+ };
+ });
+
+ // mousemove 事件监听 document 拖拽效果更流畅
+ useEventListener(document, 'mousemove', (event) => {
+ if (!isMouseDown) {
+ return;
+ }
+ handleDrag(event);
+ });
+
+ useEventListener(document, 'mouseup', () => {
+ if (!isMouseDown) {
+ return;
+ }
+ isMouseDown = false;
+ });
+
+ return {
+ handleMouseDown,
+ };
+};
diff --git a/docs/.vitepress/components/floating-pane/common.vue b/docs/.vitepress/components/floating-pane/common.vue
new file mode 100644
index 00000000..e931a70f
--- /dev/null
+++ b/docs/.vitepress/components/floating-pane/common.vue
@@ -0,0 +1,35 @@
+
+
+ 常规
+
+
+
我是内容...
+
我是内容...
+
我是内容...
+
+
+
+
+
+
diff --git a/docs/.vitepress/components/floating-pane/index.md b/docs/.vitepress/components/floating-pane/index.md
new file mode 100644
index 00000000..9f16a314
--- /dev/null
+++ b/docs/.vitepress/components/floating-pane/index.md
@@ -0,0 +1,58 @@
+# 浮动弹
+
+浮动弹窗
+
+## 组件注册
+
+```js
+import { FFloatingPane } from '@fesjs/fes-design';
+
+app.use(FFloatingPane);
+```
+
+## 代码演示
+
+### 基础用法
+
+:::demo
+common.vue
+:::
+
+## Props
+
+| 属性 | 说明 | 类型 | 默认值 |
+| ---------------- | ------------------------------------------------------------------------------ | ----------------- | ------------------------------ |
+| visible | v-model:visible,是否显示模态框 | Boolean | `false` |
+| displayDirective | 选择渲染使用的指令,if 对应 v-if,show 对应 v-show,使用 show 的时候不会被重置 | string | `show` |
+| title | 标题 | String | `-` |
+| width | 宽度 | String/Number | 520 |
+| zIndex | 浮层优先级 | Number | 3000 |
+| defaultPosition | 默认弹窗位置 | PanePosition | `{top: '50px', right: '50px'}` |
+| cachePrePosition | 是否缓存上次拖动位置 | `session` `local` | `local` |
+| contentClass | 可用于设置内容的类名 | String | `-` |
+| getContainer | 指定 `Modal` 挂载的 HTML 节点 | () => HTMLElement | `() => document.body` |
+
+### PanePosition
+
+```ts
+interface PanePosition {
+ top?: string;
+ right?: string;
+ bottom?: string;
+ left?: string;
+}
+```
+
+## Event
+
+| 事件名称 | 说明 | 回调参数 |
+| ---------- | ------------------ | -------- |
+| afterEnter | Modal 出现后的回调 | event |
+| afterLeave | Modal 关闭后的回调 | event |
+
+## Slots
+
+| 名称 | 说明 |
+| ------- | ------------ |
+| default | 模态框的内容 |
+| title | 模态框的标题 |
diff --git a/docs/.vitepress/configs/sidebar/index.ts b/docs/.vitepress/configs/sidebar/index.ts
index 78ad5230..3c3e0a1d 100644
--- a/docs/.vitepress/configs/sidebar/index.ts
+++ b/docs/.vitepress/configs/sidebar/index.ts
@@ -259,6 +259,10 @@ const sidebarConfig: Record = {
text: 'Popper 弹出信息',
link: '/zh/components/popper',
},
+ {
+ text: 'FloatingPane 可移动弹窗',
+ link: '/zh/components/floating-pane',
+ },
],
},
{
@@ -271,9 +275,7 @@ const sidebarConfig: Record = {
],
},
].map((sidebarItem) => {
- sidebarItem.items.sort(({ text: text1 }, { text: text2 }) =>
- text1 < text2 ? -1 : 1,
- );
+ sidebarItem.items.sort(({ text: text1 }, { text: text2 }) => (text1 < text2 ? -1 : 1));
return sidebarItem;
}),
'/zh/guide/': [