@gx-design-vue/pro-layout 0.1.0-alpha.12 → 0.1.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ProLayout.js +6 -1
- package/dist/components/AppPage/index.d.ts +1 -1
- package/dist/components/Breadcrumb/index.js +23 -2
- package/dist/components/Breadcrumb/interface.d.ts +5 -0
- package/dist/components/PageTransition/index.d.ts +2 -2
- package/dist/components/Tabs/InnerTabs.d.ts +8 -0
- package/dist/components/Tabs/InnerTabs.js +117 -0
- package/dist/components/Tabs/hooks/flipAnimate.d.ts +24 -0
- package/dist/components/Tabs/hooks/flipAnimate.js +40 -0
- package/dist/components/Tabs/hooks/useAutoScroll.d.ts +27 -0
- package/dist/components/Tabs/hooks/useAutoScroll.js +122 -0
- package/dist/components/Tabs/hooks/useTabDrag.d.ts +31 -0
- package/dist/components/Tabs/hooks/useTabDrag.js +252 -0
- package/dist/components/Tabs/index.d.ts +7 -1
- package/dist/components/Tabs/index.js +44 -29
- package/dist/components/Tabs/interface.d.ts +47 -6
- package/dist/components/Tabs/style/index.d.ts +34 -0
- package/dist/components/Tabs/style/index.js +140 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/defaultConfig.js +2 -1
- package/dist/hooks/useLayoutBase.d.ts +15 -13
- package/dist/hooks/useLayoutBase.js +4 -0
- package/dist/hooks/useTabs.d.ts +3 -0
- package/dist/hooks/useTabs.js +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/interface.d.ts +6 -2
- package/dist/pro-layout.esm.js +1349 -891
- package/dist/pro-layout.js +2 -2
- package/dist/style/tabs.d.ts +6 -0
- package/dist/style/tabs.js +9 -53
- package/dist/theme/augment.d.ts +4 -2
- package/dist/theme/interface/components.d.ts +4 -2
- package/package.json +5 -5
package/dist/ProLayout.js
CHANGED
|
@@ -156,6 +156,7 @@ const GProLayout = /* @__PURE__ */ defineComponent((props, { emit, expose, slots
|
|
|
156
156
|
emit("contentFullscreenChange", val);
|
|
157
157
|
}
|
|
158
158
|
});
|
|
159
|
+
const tabsHeight = ref(41);
|
|
159
160
|
function handleSelect(info) {
|
|
160
161
|
menuState.selectedKeys.value = info.keys;
|
|
161
162
|
selectedKeysModel.value = info.keys;
|
|
@@ -224,7 +225,11 @@ const GProLayout = /* @__PURE__ */ defineComponent((props, { emit, expose, slots
|
|
|
224
225
|
hasFooterToolbar.value = value;
|
|
225
226
|
},
|
|
226
227
|
navigate,
|
|
227
|
-
tabsState
|
|
228
|
+
tabsState,
|
|
229
|
+
tabsHeight,
|
|
230
|
+
setTabsHeight: (value) => {
|
|
231
|
+
tabsHeight.value = value;
|
|
232
|
+
}
|
|
228
233
|
});
|
|
229
234
|
expose({ tabs: tabsState.tabsController });
|
|
230
235
|
function resolveLayoutSlots() {
|
|
@@ -42,8 +42,8 @@ declare const ProAppPage: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
|
|
|
42
42
|
message: PropType<ConfigOptions>;
|
|
43
43
|
notification: PropType<NotificationConfig>;
|
|
44
44
|
}>> & Readonly<{}>, {
|
|
45
|
-
spinning: AppPageSpinning;
|
|
46
45
|
indicator: CustomRender;
|
|
46
|
+
spinning: AppPageSpinning;
|
|
47
47
|
}, SlotsType<{
|
|
48
48
|
default: () => any;
|
|
49
49
|
indicator: () => any;
|
|
@@ -3,6 +3,7 @@ import { renderMenuIcon } from "../Menu/iconRender.js";
|
|
|
3
3
|
import { Fragment, computed, createTextVNode, createVNode, defineComponent } from "vue";
|
|
4
4
|
import { classNames } from "@gx-design-vue/pro-utils";
|
|
5
5
|
import { Dropdown } from "antdv-next";
|
|
6
|
+
import { useRouter } from "vue-router";
|
|
6
7
|
import { GIcon } from "@gx-design-vue/icon";
|
|
7
8
|
//#region src/components/Breadcrumb/index.tsx
|
|
8
9
|
/** 首页占位路由名 */
|
|
@@ -24,11 +25,16 @@ function renderBreadcrumbItemContent(prefixCls, route) {
|
|
|
24
25
|
*/
|
|
25
26
|
const Breadcrumb = /* @__PURE__ */ defineComponent((props, { slots }) => {
|
|
26
27
|
const { navigate, prefixCls, menuState, breadcrumbConfig } = useLayoutBase();
|
|
28
|
+
const router = useRouter();
|
|
27
29
|
const breadcrumbRoutes = computed(() => props.routes ?? menuState?.breadcrumbRoutes.value ?? []);
|
|
28
30
|
const homeIconConfig = computed(() => {
|
|
29
31
|
if (props.homeIcon !== void 0) return props.homeIcon;
|
|
30
32
|
return breadcrumbConfig ? breadcrumbConfig.value?.showHome : void 0;
|
|
31
33
|
});
|
|
34
|
+
const homePath = computed(() => {
|
|
35
|
+
if (props.homePath !== void 0) return props.homePath;
|
|
36
|
+
return breadcrumbConfig?.value?.homePath ?? "/";
|
|
37
|
+
});
|
|
32
38
|
const lookup = computed(() => menuState?.lookup.value);
|
|
33
39
|
const selectedKeys = computed(() => menuState?.selectedKeys.value ?? []);
|
|
34
40
|
const displayRoutes = computed(() => {
|
|
@@ -48,13 +54,21 @@ const Breadcrumb = /* @__PURE__ */ defineComponent((props, { slots }) => {
|
|
|
48
54
|
* 首页占位项本身没有子节点,不展示 dropdown。
|
|
49
55
|
*/
|
|
50
56
|
function getDropdownChildren(route) {
|
|
51
|
-
if (route.name === BREADCRUMB_HOME_KEY)
|
|
57
|
+
if (route.name === BREADCRUMB_HOME_KEY) {
|
|
58
|
+
const visibleTopMenus = (menuState?.headerMenus.value ?? menuState?.menus.value ?? []).filter((menu) => !menu.meta?.hideInMenu);
|
|
59
|
+
return visibleTopMenus.length ? visibleTopMenus : void 0;
|
|
60
|
+
}
|
|
52
61
|
const visibleChildren = ((lookup.value?.get(route.name))?.route)?.children?.filter((child) => !child.meta?.hideInMenu);
|
|
53
62
|
return visibleChildren?.length ? visibleChildren : void 0;
|
|
54
63
|
}
|
|
55
64
|
/** 点击面包屑项导航 */
|
|
56
65
|
function handleItemClick(route, isLast) {
|
|
57
66
|
if (isLast) return;
|
|
67
|
+
if (route.name === BREADCRUMB_HOME_KEY) {
|
|
68
|
+
const targetPath = homePath.value;
|
|
69
|
+
if (router.currentRoute.value.fullPath !== targetPath) router.push(targetPath);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
58
72
|
navigate?.(route.name);
|
|
59
73
|
}
|
|
60
74
|
/** 点击 dropdown 子菜单项导航 */
|
|
@@ -82,7 +96,10 @@ const Breadcrumb = /* @__PURE__ */ defineComponent((props, { slots }) => {
|
|
|
82
96
|
})),
|
|
83
97
|
selectedKeys: dropdownSelectedKeys,
|
|
84
98
|
onClick: ({ key }) => handleDropdownMenuClick(String(key))
|
|
85
|
-
} }, { default: () => [createVNode("span", {
|
|
99
|
+
} }, { default: () => [createVNode("span", {
|
|
100
|
+
"class": `${breadcrumbCls}-overlay-link`,
|
|
101
|
+
"onClick": route.name === BREADCRUMB_HOME_KEY ? () => handleItemClick(route, isLast) : void 0
|
|
102
|
+
}, [itemContent, createVNode(GIcon, { "type": "DownOutlined" }, null)])] }) : createVNode("span", {
|
|
86
103
|
"class": classNames(`${breadcrumbCls}-link`, isLast && `${breadcrumbCls}-link-last`),
|
|
87
104
|
"onClick": () => handleItemClick(route, isLast)
|
|
88
105
|
}, [itemContent])]), !isLast && createVNode("li", { "class": `${breadcrumbCls}-separator` }, [createTextVNode("/")])]);
|
|
@@ -98,6 +115,10 @@ const Breadcrumb = /* @__PURE__ */ defineComponent((props, { slots }) => {
|
|
|
98
115
|
type: Boolean,
|
|
99
116
|
required: false,
|
|
100
117
|
default: void 0
|
|
118
|
+
},
|
|
119
|
+
homePath: {
|
|
120
|
+
type: String,
|
|
121
|
+
required: false
|
|
101
122
|
}
|
|
102
123
|
},
|
|
103
124
|
name: "LayoutBreadcrumb",
|
|
@@ -15,6 +15,11 @@ interface LayoutBreadcrumbProps {
|
|
|
15
15
|
* - `false`:不显示首页项
|
|
16
16
|
*/
|
|
17
17
|
homeIcon?: WithFalse<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* 覆盖 context 的首页点击跳转路径。
|
|
20
|
+
* 默认从 layoutContext.breadcrumbConfig.homePath 读取,缺省 '/'。
|
|
21
|
+
*/
|
|
22
|
+
homePath?: string;
|
|
18
23
|
}
|
|
19
24
|
interface LayoutBreadcrumbSlots {
|
|
20
25
|
/** 自定义每个面包屑项的渲染 */
|
|
@@ -49,10 +49,10 @@ declare const GPageTransition: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
|
|
|
49
49
|
default: string;
|
|
50
50
|
};
|
|
51
51
|
}>> & Readonly<{}>, {
|
|
52
|
-
name: string;
|
|
53
52
|
reverse: boolean;
|
|
54
|
-
disabled: boolean;
|
|
55
53
|
direction: string;
|
|
54
|
+
name: string;
|
|
55
|
+
disabled: boolean;
|
|
56
56
|
}, SlotsType<GPageTransitionSlots>, {}, {}, string, _$vue.ComponentProvideOptions, true, {}, any>;
|
|
57
57
|
//#endregion
|
|
58
58
|
export { GPageTransition as default };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { InnerTabsEmits, InnerTabsProps, InnerTabsSlots } from "./interface.js";
|
|
2
|
+
import * as _$vue from "vue";
|
|
3
|
+
import { SlotsType } from "vue";
|
|
4
|
+
|
|
5
|
+
//#region src/components/Tabs/InnerTabs.d.ts
|
|
6
|
+
declare const InnerTabs: _$vue.DefineSetupFnComponent<InnerTabsProps, InnerTabsEmits, SlotsType<InnerTabsSlots>, InnerTabsProps, _$vue.PublicProps>;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { InnerTabs as default };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useTabDrag } from "./hooks/useTabDrag.js";
|
|
2
|
+
import style_default from "./style/index.js";
|
|
3
|
+
import { Fragment, computed, createVNode, defineComponent, mergeProps, nextTick, ref, watch } from "vue";
|
|
4
|
+
import { classNames } from "@gx-design-vue/pro-utils";
|
|
5
|
+
import { useScroll } from "@vueuse/core";
|
|
6
|
+
import { useBaseConfig } from "antdv-next/config-provider/context";
|
|
7
|
+
import useCSSVarCls from "antdv-next/config-provider/hooks/useCSSVarCls";
|
|
8
|
+
import { GIcon } from "@gx-design-vue/icon";
|
|
9
|
+
//#region src/components/Tabs/InnerTabs.tsx
|
|
10
|
+
/** 溢出时两端透明裁切宽度(px) */
|
|
11
|
+
const MASK_GUTTER = 16;
|
|
12
|
+
const MASK_COLOR = "#fff";
|
|
13
|
+
const InnerTabs = /* @__PURE__ */ defineComponent((props, { slots, emit }) => {
|
|
14
|
+
const { prefixCls } = useBaseConfig("pro-inner-tabs", props);
|
|
15
|
+
const rootCls = useCSSVarCls(prefixCls);
|
|
16
|
+
const [hashId, cssVarCls] = style_default(prefixCls, rootCls);
|
|
17
|
+
const scrollRef = ref();
|
|
18
|
+
const items = computed(() => props.items ?? []);
|
|
19
|
+
const activeKey = computed(() => props.activeKey ?? "");
|
|
20
|
+
const { arrivedState } = useScroll(scrollRef);
|
|
21
|
+
const { draggingKey: tabDraggingKey, getItemProps } = useTabDrag({
|
|
22
|
+
items,
|
|
23
|
+
scrollEl: scrollRef,
|
|
24
|
+
onReorder: (fromKey, toKey) => emit("reorder", fromKey, toKey),
|
|
25
|
+
enabled: computed(() => props.draggable !== false)
|
|
26
|
+
});
|
|
27
|
+
const wrapStyle = computed(() => {
|
|
28
|
+
const atLeft = arrivedState.left;
|
|
29
|
+
const atRight = arrivedState.right;
|
|
30
|
+
if (atLeft && atRight) return {};
|
|
31
|
+
const maskImage = !atLeft && !atRight ? `linear-gradient(90deg, transparent, ${MASK_COLOR} ${MASK_GUTTER}px, ${MASK_COLOR} calc(100% - ${MASK_GUTTER}px), transparent)` : !atLeft ? `linear-gradient(90deg, transparent, ${MASK_COLOR} ${MASK_GUTTER}px)` : `linear-gradient(90deg, ${MASK_COLOR} calc(100% - ${MASK_GUTTER}px), transparent)`;
|
|
32
|
+
return {
|
|
33
|
+
WebkitMaskImage: maskImage,
|
|
34
|
+
maskImage
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
function handleTabClick(item) {
|
|
38
|
+
if (item.disabled || item.key === activeKey.value) return;
|
|
39
|
+
emit("update:activeKey", item.key);
|
|
40
|
+
emit("change", item.key);
|
|
41
|
+
}
|
|
42
|
+
function handleRemoveClick(event, item) {
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
emit("close", item.key);
|
|
46
|
+
}
|
|
47
|
+
function renderItem(item) {
|
|
48
|
+
const active = item.key === activeKey.value;
|
|
49
|
+
const closable = item.closable !== false;
|
|
50
|
+
const customTab = slots.tab?.({
|
|
51
|
+
item,
|
|
52
|
+
active
|
|
53
|
+
});
|
|
54
|
+
const dragging = tabDraggingKey.value === item.key;
|
|
55
|
+
return createVNode("div", mergeProps({
|
|
56
|
+
"key": item.key,
|
|
57
|
+
"class": classNames(`${prefixCls.value}-tab`, {
|
|
58
|
+
[`${prefixCls.value}-tab-active`]: active,
|
|
59
|
+
[`${prefixCls.value}-tab-disabled`]: item.disabled,
|
|
60
|
+
[`${prefixCls.value}-tab-dragging`]: dragging
|
|
61
|
+
}),
|
|
62
|
+
"data-key": item.key,
|
|
63
|
+
"onClick": () => handleTabClick(item)
|
|
64
|
+
}, getItemProps(item.key)), [customTab ?? createVNode(Fragment, null, [createVNode("span", { "class": `${prefixCls.value}-tab-btn` }, [item.label]), closable && createVNode(GIcon, {
|
|
65
|
+
"type": "CloseOutlined",
|
|
66
|
+
"class": `${prefixCls.value}-tab-remove`,
|
|
67
|
+
"data-drag-ignore": "true",
|
|
68
|
+
"onClick": (event) => handleRemoveClick(event, item)
|
|
69
|
+
}, null)])]);
|
|
70
|
+
}
|
|
71
|
+
watch(() => [props.activeKey, items.value.length], () => {
|
|
72
|
+
nextTick(() => {
|
|
73
|
+
(scrollRef.value?.querySelector(`[data-key="${activeKey.value}"]`))?.scrollIntoView?.({
|
|
74
|
+
inline: "nearest",
|
|
75
|
+
block: "nearest"
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}, { immediate: true });
|
|
79
|
+
return () => {
|
|
80
|
+
if (items.value.length === 0) return null;
|
|
81
|
+
return createVNode("div", { "class": classNames(prefixCls.value, hashId.value, cssVarCls.value, rootCls.value) }, [createVNode("div", { "class": `${prefixCls.value}-nav` }, [createVNode("div", {
|
|
82
|
+
"ref": scrollRef,
|
|
83
|
+
"class": `${prefixCls.value}-nav-wrap`,
|
|
84
|
+
"style": wrapStyle.value
|
|
85
|
+
}, [createVNode("div", { "class": `${prefixCls.value}-nav-list` }, [items.value.map((item) => renderItem(item))])]), slots.extra && createVNode("div", { "class": `${prefixCls.value}-extra` }, [slots.extra()])])]);
|
|
86
|
+
};
|
|
87
|
+
}, {
|
|
88
|
+
props: {
|
|
89
|
+
prefixCls: {
|
|
90
|
+
type: String,
|
|
91
|
+
required: false
|
|
92
|
+
},
|
|
93
|
+
items: {
|
|
94
|
+
type: Array,
|
|
95
|
+
required: false
|
|
96
|
+
},
|
|
97
|
+
activeKey: {
|
|
98
|
+
type: String,
|
|
99
|
+
required: false
|
|
100
|
+
},
|
|
101
|
+
draggable: {
|
|
102
|
+
type: Boolean,
|
|
103
|
+
required: false,
|
|
104
|
+
default: void 0
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
emits: [
|
|
108
|
+
"update:activeKey",
|
|
109
|
+
"change",
|
|
110
|
+
"close",
|
|
111
|
+
"reorder"
|
|
112
|
+
],
|
|
113
|
+
name: "InnerTabs",
|
|
114
|
+
inheritAttrs: false
|
|
115
|
+
});
|
|
116
|
+
//#endregion
|
|
117
|
+
export { InnerTabs as default };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/components/Tabs/hooks/flipAnimate.d.ts
|
|
2
|
+
interface FlipAnimateOptions {
|
|
3
|
+
/** 动画时长 ms,默认 200 */
|
|
4
|
+
duration?: number;
|
|
5
|
+
/** 缓动函数,默认 cubic-bezier(0.2,0,0,1)(对齐 dnd-kit motionEase) */
|
|
6
|
+
easing?: string;
|
|
7
|
+
}
|
|
8
|
+
interface FlipEntry {
|
|
9
|
+
key: string;
|
|
10
|
+
el: HTMLElement;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* FLIP 动画 —— 精简复刻 `@dnd-kit/dom` 的 `sortable#animate`。
|
|
14
|
+
*
|
|
15
|
+
* 用 Web Animations API(`element.animate`)+ CSS `translate` 属性(非 transform):
|
|
16
|
+
* 1. 取消元素上 transform/translate/scale 的 CSS transition(避免 getBoundingClientRect 取到中间帧)
|
|
17
|
+
* 2. 测新旧位置算 `delta = prev.left - next.left`
|
|
18
|
+
* 3. `el.animate({ translate: [delta, 0] }, { duration, easing })`
|
|
19
|
+
*
|
|
20
|
+
* 命中 `prefers-reduced-motion` 时 duration 置 0。
|
|
21
|
+
*/
|
|
22
|
+
declare function flipAnimate(entries: FlipEntry[], prevRects: Map<string, DOMRect>, options?: FlipAnimateOptions): void;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { FlipAnimateOptions, FlipEntry, flipAnimate };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/components/Tabs/hooks/flipAnimate.ts
|
|
2
|
+
/**
|
|
3
|
+
* FLIP 动画 —— 精简复刻 `@dnd-kit/dom` 的 `sortable#animate`。
|
|
4
|
+
*
|
|
5
|
+
* 用 Web Animations API(`element.animate`)+ CSS `translate` 属性(非 transform):
|
|
6
|
+
* 1. 取消元素上 transform/translate/scale 的 CSS transition(避免 getBoundingClientRect 取到中间帧)
|
|
7
|
+
* 2. 测新旧位置算 `delta = prev.left - next.left`
|
|
8
|
+
* 3. `el.animate({ translate: [delta, 0] }, { duration, easing })`
|
|
9
|
+
*
|
|
10
|
+
* 命中 `prefers-reduced-motion` 时 duration 置 0。
|
|
11
|
+
*/
|
|
12
|
+
function flipAnimate(entries, prevRects, options = {}) {
|
|
13
|
+
const { duration = 200, easing = "cubic-bezier(0.2, 0, 0, 1)" } = options;
|
|
14
|
+
const finalDuration = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : duration;
|
|
15
|
+
for (const { key, el } of entries) {
|
|
16
|
+
const prev = prevRects.get(key);
|
|
17
|
+
if (!prev) continue;
|
|
18
|
+
for (const animation of el.getAnimations()) {
|
|
19
|
+
const transitionProperty = animation.transitionProperty;
|
|
20
|
+
if (transitionProperty === "transform" || transitionProperty === "translate" || transitionProperty === "scale") animation.cancel();
|
|
21
|
+
}
|
|
22
|
+
const next = el.getBoundingClientRect();
|
|
23
|
+
const deltaX = prev.left - next.left;
|
|
24
|
+
const deltaY = prev.top - next.top;
|
|
25
|
+
if (deltaX === 0 && deltaY === 0) continue;
|
|
26
|
+
const animation = el.animate({ translate: [`${deltaX}px ${deltaY}px`, "0px 0px"] }, {
|
|
27
|
+
duration: finalDuration,
|
|
28
|
+
easing
|
|
29
|
+
});
|
|
30
|
+
const clearTranslate = () => {
|
|
31
|
+
el.style.translate = "";
|
|
32
|
+
};
|
|
33
|
+
if ("addEventListener" in animation) {
|
|
34
|
+
animation.addEventListener("finish", clearTranslate, { once: true });
|
|
35
|
+
animation.addEventListener("cancel", clearTranslate, { once: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { flipAnimate };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
//#region src/components/Tabs/hooks/useAutoScroll.d.ts
|
|
4
|
+
interface UseAutoScrollOptions {
|
|
5
|
+
/** 边缘激活区域占容器宽度比例,默认 0.2(对齐 dnd-kit detectScrollIntent threshold) */
|
|
6
|
+
threshold?: number;
|
|
7
|
+
/** 滚动加速度(基础速度),默认 25 */
|
|
8
|
+
acceleration?: number;
|
|
9
|
+
/** 指针超出边缘的容差(px),默认 10 */
|
|
10
|
+
tolerance?: number;
|
|
11
|
+
/** 滚动后回调,用于同步拖拽元素和让位元素位置 */
|
|
12
|
+
onScroll?: () => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 横向自动滚动 —— 精简复刻 `@dnd-kit/dom` 的 `detectScrollIntent` + `Scroller` 思路。
|
|
16
|
+
*
|
|
17
|
+
* 指针进入 `scrollEl` 左/右边缘 threshold 区域时,按距边缘的比例计算速度
|
|
18
|
+
* (越靠边越快)。滚动方向必须先被用户同向移动解锁;如果当前方向已经到达边界,
|
|
19
|
+
* 则不产生滚动 intent,避免触底后因为鼠标还在边缘区被反复重新激活。
|
|
20
|
+
*/
|
|
21
|
+
declare function useAutoScroll(scrollEl: Ref<HTMLElement | undefined>, getPointerX: () => number | null, options?: UseAutoScrollOptions): {
|
|
22
|
+
scroll: () => boolean;
|
|
23
|
+
start: () => void;
|
|
24
|
+
stop: () => void;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { UseAutoScrollOptions, useAutoScroll };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
//#region src/components/Tabs/hooks/useAutoScroll.ts
|
|
2
|
+
const ScrollDirection = {
|
|
3
|
+
Idle: 0,
|
|
4
|
+
Forward: 1,
|
|
5
|
+
Reverse: -1
|
|
6
|
+
};
|
|
7
|
+
const SCROLL_EPSILON = .5;
|
|
8
|
+
/**
|
|
9
|
+
* 横向自动滚动 —— 精简复刻 `@dnd-kit/dom` 的 `detectScrollIntent` + `Scroller` 思路。
|
|
10
|
+
*
|
|
11
|
+
* 指针进入 `scrollEl` 左/右边缘 threshold 区域时,按距边缘的比例计算速度
|
|
12
|
+
* (越靠边越快)。滚动方向必须先被用户同向移动解锁;如果当前方向已经到达边界,
|
|
13
|
+
* 则不产生滚动 intent,避免触底后因为鼠标还在边缘区被反复重新激活。
|
|
14
|
+
*/
|
|
15
|
+
function useAutoScroll(scrollEl, getPointerX, options = {}) {
|
|
16
|
+
const { threshold = .2, acceleration = 18, tolerance = 10, onScroll } = options;
|
|
17
|
+
let rafId;
|
|
18
|
+
let lastPointerX = null;
|
|
19
|
+
let maxScrollLeft;
|
|
20
|
+
const unlockedDirections = /* @__PURE__ */ new Set();
|
|
21
|
+
function getMaxScrollLeft() {
|
|
22
|
+
const el = scrollEl.value;
|
|
23
|
+
if (!el) return 0;
|
|
24
|
+
return maxScrollLeft ?? Math.max(0, el.scrollWidth - el.clientWidth);
|
|
25
|
+
}
|
|
26
|
+
function getEdgeIntent() {
|
|
27
|
+
const el = scrollEl.value;
|
|
28
|
+
const pointerX = getPointerX();
|
|
29
|
+
if (!el || pointerX == null) return {
|
|
30
|
+
direction: ScrollDirection.Idle,
|
|
31
|
+
speed: 0
|
|
32
|
+
};
|
|
33
|
+
const rect = el.getBoundingClientRect();
|
|
34
|
+
const triggerWidth = rect.width * threshold;
|
|
35
|
+
if (triggerWidth <= 0) return {
|
|
36
|
+
direction: ScrollDirection.Idle,
|
|
37
|
+
speed: 0
|
|
38
|
+
};
|
|
39
|
+
let direction = ScrollDirection.Idle;
|
|
40
|
+
let speed = 0;
|
|
41
|
+
const distRight = rect.right - pointerX;
|
|
42
|
+
const distLeft = pointerX - rect.left;
|
|
43
|
+
const canScrollRight = canScroll(ScrollDirection.Forward);
|
|
44
|
+
const canScrollLeft = canScroll(ScrollDirection.Reverse);
|
|
45
|
+
if (canScrollRight && distRight <= triggerWidth && pointerX <= rect.right + tolerance) {
|
|
46
|
+
const ratio = Math.abs((rect.right - triggerWidth - pointerX) / triggerWidth);
|
|
47
|
+
direction = ScrollDirection.Forward;
|
|
48
|
+
speed = acceleration * Math.min(ratio, 1);
|
|
49
|
+
} else if (canScrollLeft && distLeft <= triggerWidth && pointerX >= rect.left - tolerance) {
|
|
50
|
+
const ratio = Math.abs((rect.left + triggerWidth - pointerX) / triggerWidth);
|
|
51
|
+
direction = ScrollDirection.Reverse;
|
|
52
|
+
speed = acceleration * Math.min(ratio, 1);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
direction,
|
|
56
|
+
speed
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function canScroll(direction) {
|
|
60
|
+
const el = scrollEl.value;
|
|
61
|
+
if (!el) return false;
|
|
62
|
+
const max = getMaxScrollLeft();
|
|
63
|
+
if (direction === ScrollDirection.Forward) return el.scrollLeft < max - SCROLL_EPSILON;
|
|
64
|
+
if (direction === ScrollDirection.Reverse) return el.scrollLeft > SCROLL_EPSILON;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
function applyScroll() {
|
|
68
|
+
const el = scrollEl.value;
|
|
69
|
+
if (!el) return false;
|
|
70
|
+
const { direction, speed } = getEdgeIntent();
|
|
71
|
+
if (direction === ScrollDirection.Idle) return false;
|
|
72
|
+
if (!unlockedDirections.has(direction)) return false;
|
|
73
|
+
if (!canScroll(direction)) return false;
|
|
74
|
+
const delta = direction * speed;
|
|
75
|
+
if (delta !== 0) {
|
|
76
|
+
const before = el.scrollLeft;
|
|
77
|
+
const next = Math.min(getMaxScrollLeft(), Math.max(0, before + delta));
|
|
78
|
+
if (Math.abs(next - before) <= SCROLL_EPSILON) return false;
|
|
79
|
+
el.scrollLeft = next;
|
|
80
|
+
onScroll?.();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function tick() {
|
|
86
|
+
rafId = void 0;
|
|
87
|
+
if (applyScroll()) rafId = requestAnimationFrame(tick);
|
|
88
|
+
}
|
|
89
|
+
function scroll() {
|
|
90
|
+
const pointerX = getPointerX();
|
|
91
|
+
if (pointerX == null) return false;
|
|
92
|
+
if (lastPointerX != null) {
|
|
93
|
+
const moveDirection = Math.sign(pointerX - lastPointerX);
|
|
94
|
+
if (moveDirection !== ScrollDirection.Idle) unlockedDirections.add(moveDirection);
|
|
95
|
+
}
|
|
96
|
+
lastPointerX = pointerX;
|
|
97
|
+
const didScroll = applyScroll();
|
|
98
|
+
if (didScroll) rafId ??= requestAnimationFrame(tick);
|
|
99
|
+
return didScroll;
|
|
100
|
+
}
|
|
101
|
+
function start() {
|
|
102
|
+
const el = scrollEl.value;
|
|
103
|
+
maxScrollLeft = el ? Math.max(0, el.scrollWidth - el.clientWidth) : void 0;
|
|
104
|
+
scroll();
|
|
105
|
+
}
|
|
106
|
+
function stop() {
|
|
107
|
+
if (rafId != null) {
|
|
108
|
+
cancelAnimationFrame(rafId);
|
|
109
|
+
rafId = void 0;
|
|
110
|
+
}
|
|
111
|
+
lastPointerX = null;
|
|
112
|
+
maxScrollLeft = void 0;
|
|
113
|
+
unlockedDirections.clear();
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
scroll,
|
|
117
|
+
start,
|
|
118
|
+
stop
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
export { useAutoScroll };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { InnerTabItem } from "../interface.js";
|
|
2
|
+
import { ComputedRef, Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
//#region src/components/Tabs/hooks/useTabDrag.d.ts
|
|
5
|
+
interface UseTabDragOptions {
|
|
6
|
+
items: ComputedRef<InnerTabItem[]>;
|
|
7
|
+
scrollEl: Ref<HTMLElement | undefined>;
|
|
8
|
+
onReorder: (fromKey: string, toKey: string) => void;
|
|
9
|
+
enabled?: ComputedRef<boolean>;
|
|
10
|
+
}
|
|
11
|
+
interface TabDragItemProps {
|
|
12
|
+
onMousedown: (event: MouseEvent) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 页签拖拽排序 —— 精简复刻 dnd-kit 的 useDraggable + sortable 实时让位。
|
|
16
|
+
*
|
|
17
|
+
* - 移动阈值(3px)区分点击与拖拽:未超阈值视为点击,让原生 click 走切换
|
|
18
|
+
* - 拖拽项 `transform: translateX(delta)` 跟随指针
|
|
19
|
+
* - 同组其他 tab 按虚拟插入位置实时 `translate` 让位(CSS translate 属性)
|
|
20
|
+
* - drop 锚点只在 `item.group === dragGroup` 内查找 → 天然不跨级
|
|
21
|
+
* - 松手 → onReorder 触发数据重排 → nextTick 用 flipAnimate(WAAPI FLIP)平滑归位
|
|
22
|
+
* - 拖拽结束后捕获抑制下一次 click,避免误触发 tab 切换
|
|
23
|
+
* - 边缘自动滚动串联 useAutoScroll
|
|
24
|
+
*/
|
|
25
|
+
declare function useTabDrag(options: UseTabDragOptions): {
|
|
26
|
+
isDragging: Ref<boolean, boolean>;
|
|
27
|
+
draggingKey: Ref<string, string>;
|
|
28
|
+
getItemProps: (key: string) => TabDragItemProps;
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { TabDragItemProps, UseTabDragOptions, useTabDrag };
|