Skip to content

Commit

Permalink
feat(FloatPane): 浮动弹窗组件 (#817)
Browse files Browse the repository at this point in the history
  • Loading branch information
winixt committed May 29, 2024
1 parent 7f34fe8 commit a57a366
Show file tree
Hide file tree
Showing 12 changed files with 475 additions and 3 deletions.
1 change: 1 addition & 0 deletions components/_style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ import './input-file/style';
import './breadcrumb/style';
import './text-highlight/style';
import './link/style';
import './float-pane/style';
11 changes: 11 additions & 0 deletions components/_util/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const prefixStorage = '__fesd-storage';

export function getPrefixStorage(suffix: string) {
return suffix ? `${prefixStorage}-${suffix}` : prefixStorage;
}

export type StorageType = 'local' | 'session';

export function getStorage(type: StorageType) {
return type === 'local' ? localStorage : sessionStorage;
}
1 change: 1 addition & 0 deletions components/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ export * from './input-file';
export * from './breadcrumb';
export * from './text-highlight';
export * from './link';
export * from './float-pane';
159 changes: 159 additions & 0 deletions components/float-pane/float-pane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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, getStorage } from '../_util/storage';

import { floatPaneProps } from './props';
import { useDrag } from './useDrag';

const prefixCls = getPrefixCls('float-pane');
const UPDATE_VISIBLE_EVENT = 'update:visible';
const AFTER_ENTER_EVENT = 'after-enter';
const AFTER_LEAVE_EVENT = 'after-leave';

const FloatPane = defineComponent({
name: 'FFloatPane',
props: floatPaneProps,
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);

const transform = props.cachePosition
? useStorage<{
offsetX: number;
offsetY: number;
}>(getPrefixStorage('float-pane'), {
offsetX: 0,
offsetY: 0,
}, getStorage(props.cachePosition))
: 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, isDragging } = useDrag(transform);
const handleDraggable = (event: MouseEvent) => {
if (props.draggable) {
handleMouseDown(event);
}
};

function getHeader() {
const closeJsx = (
<div class={`${prefixCls}-close`} onClick={handleCancel}>
<CloseOutlined />
</div>
);
if (!hasHeader.value) {
return closeJsx;
}
const header = ctx.slots.title?.() || props.title;
return (
<div class={[`${prefixCls}-header`, isDragging.value && `${prefixCls}-header--dragging`]} onMousedown={handleDraggable}>
<div>{header}</div>
{closeJsx}
</div>
);
}

const getBody = () => {
return (
<div class={`${prefixCls}-body`}>
{ctx.slots.default?.()}
</div>
);
};

const showDom = computed(
() =>
(props.displayDirective === 'if' && innerVisible.value)
|| props.displayDirective === 'show',
);

const wrapperClass = computed(() => {
return [`${prefixCls}-container`, props.contentClass].filter(Boolean);
});

return () => (
<Teleport
disabled={!getContainer.value?.()}
to={getContainer.value?.()}
>
<div class={prefixCls}>
<Transition
name={`${prefixCls}-fade`}
onAfterEnter={handleTransitionAfterEnter}
onAfterLeave={handleTransitionAfterLeave}
>
{showDom.value && (
<div
v-show={innerVisible.value}
class={wrapperClass.value}
style={styles.value}
>
{getHeader()}
{getBody()}
</div>
)}
</Transition>
</div>
</Teleport>
);
},
});

export default FloatPane;
11 changes: 11 additions & 0 deletions components/float-pane/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { withInstall } from '../_util/withInstall';
import type { SFCWithInstall } from '../_util/interface';
import FloatPane from './float-pane';

type ModalType = SFCWithInstall<typeof FloatPane>;

export { floatPaneProps } from './props';
export type { FloatPaneProps } from './props';
export const FFloatPane = withInstall<ModalType>(FloatPane as ModalType);

export default FFloatPane;
52 changes: 52 additions & 0 deletions components/float-pane/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ComponentObjectPropsOptions, PropType, VNode, VNodeChild } from 'vue';
import type { ExtractPublicPropTypes } from '../_util/interface';
import type { StorageType } from '../_util/storage';

export interface PanePosition {
top?: string;
right?: string;
bottom?: string;
left?: string;
}

// 通用的属性
export const floatPaneProps = {
visible: Boolean,
displayDirective: {
type: String as PropType<'show' | 'if'>,
default: 'show',
},
draggable: {
type: Boolean,
default: true,
},
title: String as PropType<string | VNode | (() => VNodeChild)>,
width: {
type: [String, Number] as PropType<string | number>,
default: 520,
},
zIndex: {
type: Number,
default: 3000,
},
defaultPosition: {
type: Object as PropType<PanePosition>,
default(): PanePosition {
return {
bottom: '50px',
right: '50px',
};
},
},
cachePosition: {
type: String as PropType<StorageType>,
default: 'local',
},
getContainer: {
type: Function as PropType<() => HTMLElement>,
},
// 内容外层类名
contentClass: String,
} as const satisfies ComponentObjectPropsOptions;

export type FloatPaneProps = ExtractPublicPropTypes<typeof floatPaneProps>;
70 changes: 70 additions & 0 deletions components/float-pane/style/index.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
@import '../../style/themes/index';
@import '../../style/mixins/index';

@modal-prefix-cls: ~'@{cls-prefix}-float-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);

&--dragging {
cursor: move;
}

.@{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;
}
}

&-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;
}
}
2 changes: 2 additions & 0 deletions components/float-pane/style/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import '../../style';
import './index.less';
61 changes: 61 additions & 0 deletions components/float-pane/useDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type Ref, ref } from 'vue';
import { throttle } from 'lodash-es';
import { useEventListener } from '@vueuse/core';

export const useDrag = (
transform: Ref<{
offsetX: number;
offsetY: number;
}>,
) => {
const isDragging = ref(false);

let startX: number;
let startY: number;
let imgOffsetX: number;
let imgOffsetY: number;

const handleMouseDown = (event: MouseEvent) => {
// 取消默认图片拖拽的行为
event.preventDefault();
isDragging.value = true;
// 存储鼠标按下的偏移量和事件发生坐标
const { offsetX, offsetY } = transform.value;
startX = event.pageX;
startY = event.pageY;
imgOffsetX = offsetX;
imgOffsetY = offsetY;
};

const handleDrag = throttle((event: MouseEvent) => {
// 避免移动到窗口外
if (event.clientY <= 0 || event.clientX <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
return;
}
transform.value = {
...transform.value,
offsetX: imgOffsetX + event.pageX - startX,
offsetY: imgOffsetY + event.pageY - startY,
};
});

// mousemove 事件监听 document 拖拽效果更流畅
useEventListener(document, 'mousemove', (event) => {
if (!isDragging.value) {
return;
}
handleDrag(event);
});

useEventListener(document, 'mouseup', () => {
if (!isDragging.value) {
return;
}
isDragging.value = false;
});

return {
handleMouseDown,
isDragging,
};
};
Loading

0 comments on commit a57a366

Please sign in to comment.